aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorRblSb <msrblsb@gmail.com>2025-03-25 03:02:03 +0300
committerRblSb <msrblsb@gmail.com>2025-03-25 08:16:04 +0300
commitf874dcd3de368e7e512ab1c0defdd17bc3026ce5 (patch)
tree74fecdf08577f2fc03b09b1170b9ffea9163af04
parent8ee20a84cb35968f9247028a6a2daa57f04e90bb (diff)
Initial cache support for raw videos
m3u8 videos are cached without downloading segments, only m3u8 file is downloaded and segment links are updated to use synctube proxy, so you can add video to playlist as server, ignoring ip restrictions, and stream it to everyone
-rw-r--r--.github/workflows/main.yml2
-rw-r--r--build/server.js1204
-rw-r--r--res/client.js77
-rw-r--r--src/VideoList.hx11
-rw-r--r--src/client/Buttons.hx21
-rw-r--r--src/client/ClientSettings.hx31
-rw-r--r--src/client/Main.hx23
-rw-r--r--src/server/ConsoleInput.hx2
-rw-r--r--src/server/HttpServer.hx1
-rw-r--r--src/server/Main.hx16
-rw-r--r--src/server/cache/Cache.hx171
-rw-r--r--src/server/cache/RawCache.hx469
-rw-r--r--src/server/cache/YoutubeCache.hx (renamed from src/server/Cache.hx)221
13 files changed, 1578 insertions, 671 deletions
diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml
index 2c93bc3..35c0c18 100644
--- a/.github/workflows/main.yml
+++ b/.github/workflows/main.yml
@@ -16,7 +16,7 @@ jobs:
run: |
node -v
npm install --global lix
- lix install haxe 4.3.0 --global
+ lix install haxe 4.3.6 --global
lix download
npm ci
haxelib install tests.hxml --always
diff --git a/build/server.js b/build/server.js
index ef1de33..a6b4b7e 100644
--- a/build/server.js
+++ b/build/server.js
@@ -2237,6 +2237,17 @@ Lambda.find = function(it,f) {
}
return null;
};
+Lambda.findIndex = function(it,f) {
+ var i = 0;
+ var v = $getIterator(it);
+ while(v.hasNext()) {
+ if(f(v.next())) {
+ return i;
+ }
+ ++i;
+ }
+ return -1;
+};
var haxe_IMap = function() { };
haxe_IMap.__name__ = true;
haxe_IMap.__isInterface__ = true;
@@ -2454,16 +2465,7 @@ VideoList.prototype = {
return Lambda.exists(this.items,f);
}
,findIndex: function(f) {
- var i = 0;
- var _g = 0;
- var _g1 = this.items;
- while(_g < _g1.length) {
- if(f(_g1[_g++])) {
- return i;
- }
- ++i;
- }
- return -1;
+ return Lambda.findIndex(this.items,f);
}
,addItem: function(item,atEnd) {
if(atEnd) {
@@ -3679,415 +3681,6 @@ json2object_PositionUtils.prototype = {
}
,__class__: json2object_PositionUtils
};
-var server_Cache = function(main,cacheDir) {
- this.freeSpaceBlock = 10485760;
- this.cachedFiles = [];
- this.storageLimit = 3145728 * 1024;
- this.isYtReady = false;
- this.notEnoughSpaceErrorText = "Error: Not enough free space on server or file size is out of cache storage limit.";
- this.main = main;
- this.cacheDir = cacheDir;
- server_Utils.ensureDir(cacheDir);
- this.isYtReady = this.checkYtDeps();
- if(this.isYtReady) {
- this.cleanYtInputFiles();
- }
-};
-server_Cache.__name__ = true;
-server_Cache.prototype = {
- checkYtDeps: function() {
- var ytdl;
- try {
- ytdl = require("@distube/ytdl-core");
- } catch( _g ) {
- return false;
- }
- try {
- js_node_ChildProcess.execSync("ffmpeg -version",{ stdio : "ignore", timeout : 3000});
- return true;
- } catch( _g ) {
- return false;
- }
- }
- ,cleanYtInputFiles: function() {
- var names = js_node_Fs.readdirSync(this.cacheDir);
- var _g = 0;
- while(_g < names.length) {
- var name = names[_g];
- ++_g;
- if(!StringTools.startsWith(name,"__tmp")) {
- continue;
- }
- this.remove(name);
- }
- }
- ,getCachedFiles: function() {
- return this.cachedFiles;
- }
- ,setCachedFiles: function(names) {
- this.cachedFiles.length = 0;
- var _g = 0;
- while(_g < names.length) this.cachedFiles.push(names[_g++]);
- var names = js_node_Fs.readdirSync(this.cacheDir);
- var _g = 0;
- while(_g < names.length) {
- var name = names[_g];
- ++_g;
- if(StringTools.startsWith(name,".")) {
- continue;
- }
- if(sys_FileSystem.isDirectory("" + this.cacheDir + "/" + name)) {
- continue;
- }
- if(this.cachedFiles.indexOf(name) != -1) {
- continue;
- }
- haxe_Log.trace("Remove non-tracked cache " + name,{ fileName : "src/server/Cache.hx", lineNumber : 70, className : "server.Cache", methodName : "setCachedFiles"});
- this.remove(name);
- }
- }
- ,log: function(client,msg) {
- this.main.serverMessage(client,msg);
- haxe_Log.trace(msg,{ fileName : "src/server/Cache.hx", lineNumber : 77, className : "server.Cache", methodName : "log"});
- }
- ,cacheYoutubeVideo: function(client,url,callback) {
- var _gthis = this;
- if(!this.isYtReady) {
- haxe_Log.trace("Do `npm i @distube/ytdl-core@latest` to use cache feature (you also need to install `ffmpeg` to build mp4 from downloaded audio/video tracks).",{ fileName : "src/server/Cache.hx", lineNumber : 82, className : "server.Cache", methodName : "cacheYoutubeVideo"});
- return;
- }
- var videoId = utils_YoutubeUtils.extractVideoId(url);
- if(videoId == "") {
- this.log(client,"Error: youtube video id not found in url: " + url);
- return;
- }
- var outName = videoId + ".mp4";
- if(this.cachedFiles.indexOf(outName) != -1 && this.isFileExists(outName)) {
- callback(outName);
- return;
- }
- var inVideoName = "__tmp-video-" + videoId;
- var inAudioName = "__tmp-audio-" + videoId;
- if(this.isFileExists(inVideoName)) {
- this.log(client,"Caching " + outName + " already in progress");
- return;
- }
- var ytdl = require("@distube/ytdl-core");
- haxe_Log.trace("Caching " + url + " to " + outName + "...",{ fileName : "src/server/Cache.hx", lineNumber : 125, className : "server.Cache", methodName : "cacheYoutubeVideo"});
- this.main.send(client,{ type : "Progress", progress : { type : "Caching", ratio : 0, data : outName}});
- var agent = null;
- var cookiesPath = "" + this.main.userDir + "/cookies.json";
- if(sys_FileSystem.exists(cookiesPath)) {
- agent = ytdl.createAgent(JSON.parse(js_node_Fs.readFileSync(cookiesPath,{ encoding : "utf8"})));
- }
- var promise = ytdl.getInfo(url,{ agent : agent});
- promise.then(function(info) {
- haxe_Log.trace("Get info with " + info.formats.length + " formats",{ fileName : "src/server/Cache.hx", lineNumber : 143, className : "server.Cache", methodName : "cacheYoutubeVideo"});
- var audioFormat;
- try {
- var ytdl1 = ytdl.chooseFormat;
- var _g = [];
- var _g1 = 0;
- var _g2 = info.formats;
- while(_g1 < _g2.length) {
- var v = _g2[_g1];
- ++_g1;
- var tmp = v.audioCodec;
- if(tmp != null ? StringTools.startsWith(tmp,"mp4a") : null) {
- _g.push(v);
- }
- }
- audioFormat = ytdl1(_g,{ quality : "highestaudio"});
- } catch( _g ) {
- var e = haxe_Exception.caught(_g);
- _gthis.log(client,"Error: audio format not found");
- haxe_Log.trace(e,{ fileName : "src/server/Cache.hx", lineNumber : 150, className : "server.Cache", methodName : "cacheYoutubeVideo"});
- var _g1 = [];
- var _g2 = 0;
- var _g3 = info.formats;
- while(_g2 < _g3.length) {
- var v = _g3[_g2];
- ++_g2;
- if(v.hasAudio) {
- _g1.push(v);
- }
- }
- haxe_Log.trace(_g1,{ fileName : "src/server/Cache.hx", lineNumber : 151, className : "server.Cache", methodName : "cacheYoutubeVideo"});
- return;
- }
- var videoFormat;
- var tmp = _gthis.getBestYoutubeVideoFormat(info.formats);
- if(tmp != null) {
- videoFormat = tmp;
- } else {
- _gthis.log(client,"Error: video format not found");
- var _g = [];
- var _g1 = 0;
- var _g2 = info.formats;
- while(_g1 < _g2.length) {
- var v = _g2[_g1];
- ++_g1;
- if(v.hasVideo) {
- _g.push(v);
- }
- }
- haxe_Log.trace(_g,{ fileName : "src/server/Cache.hx", lineNumber : 156, className : "server.Cache", methodName : "cacheYoutubeVideo"});
- return;
- }
- var tmp = Std.parseInt(videoFormat.contentLength);
- var videoSize = tmp != null ? tmp : 0;
- var tmp = Std.parseInt(audioFormat.contentLength);
- var audioSize = tmp != null ? tmp : 0;
- var hasSpace = _gthis.removeOlderCache((videoSize + audioSize) * 2 + _gthis.freeSpaceBlock);
- if(!hasSpace) {
- videoFormat = _gthis.getBestYoutubeVideoFormat(info.formats,videoFormat.qualityLabel);
- var tmp = Std.parseInt(videoFormat.contentLength);
- var videoSize = tmp != null ? tmp : 0;
- var tmp = Std.parseInt(audioFormat.contentLength);
- var audioSize = tmp != null ? tmp : 0;
- var hasSpace = _gthis.removeOlderCache((videoSize + audioSize) * 2 + _gthis.freeSpaceBlock);
- if(!hasSpace) {
- _gthis.remove(inVideoName);
- _gthis.remove(inAudioName);
- _gthis.main.send(client,{ type : "Progress", progress : { type : "Canceled", ratio : 1}});
- _gthis.log(client,_gthis.notEnoughSpaceErrorText);
- }
- if(!hasSpace) {
- return;
- }
- }
- var dlVideo = ytdl(url,{ format : videoFormat, agent : agent});
- dlVideo.pipe(js_node_Fs.createWriteStream("" + _gthis.cacheDir + "/" + inVideoName));
- dlVideo.on("error",function(err) {
- _gthis.log(client,"Error during video download: " + err);
- _gthis.remove(inVideoName);
- _gthis.remove(inAudioName);
- _gthis.main.send(client,{ type : "Progress", progress : { type : "Canceled", ratio : 1}});
- });
- var dlAudio = ytdl(url,{ format : audioFormat, agent : agent});
- dlAudio.pipe(js_node_Fs.createWriteStream("" + _gthis.cacheDir + "/" + inAudioName));
- dlAudio.on("error",function(err) {
- _gthis.log(client,"Error during audio download: " + err);
- _gthis.remove(inVideoName);
- _gthis.remove(inAudioName);
- _gthis.main.send(client,{ type : "Progress", progress : { type : "Canceled", ratio : 1}});
- });
- var count = 0;
- var onComplete = function(type) {
- count += 1;
- haxe_Log.trace("" + type + " track downloaded (" + count + "/2)",{ fileName : "src/server/Cache.hx", lineNumber : 197, className : "server.Cache", methodName : "cacheYoutubeVideo"});
- if(count < 2) {
- return;
- }
- if(!_gthis.isFileExists(inVideoName) || !_gthis.isFileExists(inAudioName)) {
- _gthis.log(client,"Input files not found for making final video");
- _gthis.remove(inVideoName);
- _gthis.remove(inAudioName);
- _gthis.main.send(client,{ type : "Progress", progress : { type : "Canceled", ratio : 1}});
- return;
- }
- var size = js_node_Fs.statSync("" + _gthis.cacheDir + "/" + inVideoName).size;
- size += js_node_Fs.statSync("" + _gthis.cacheDir + "/" + inAudioName).size;
- var hasSpace = _gthis.removeOlderCache(size + _gthis.freeSpaceBlock);
- if(!hasSpace) {
- _gthis.remove(inVideoName);
- _gthis.remove(inAudioName);
- _gthis.main.send(client,{ type : "Progress", progress : { type : "Canceled", ratio : 1}});
- _gthis.log(client,_gthis.notEnoughSpaceErrorText);
- }
- if(!hasSpace) {
- return;
- }
- var args = ("-y -i ./" + inVideoName + " -i ./" + inAudioName + " -c copy -map 0:v -map 1:a ./" + outName).split(" ");
- var $process = js_node_ChildProcess.spawn("ffmpeg",args,{ cwd : _gthis.cacheDir});
- var outputData = [];
- $process.stderr.on("data",function(data) {
- return outputData.push(data);
- });
- $process.on("close",function(code) {
- _gthis.remove(inVideoName);
- _gthis.remove(inAudioName);
- if(code != 0) {
- _gthis.main.send(client,{ type : "Progress", progress : { type : "Canceled", ratio : 1}});
- var errCodeMsg = "Error: ffmpeg closed with code " + code;
- var _g = [];
- var _g1 = 0;
- var _g2 = _gthis.main.clients;
- while(_g1 < _g2.length) {
- var v = _g2[_g1];
- ++_g1;
- if((v.group & 1 << ClientGroup.Admin._hx_index) != 0) {
- _g.push(v);
- }
- }
- var admins = _g;
- var _g = 0;
- while(_g < admins.length) {
- var client1 = admins[_g];
- ++_g;
- _gthis.log(client1,js_node_buffer_Buffer.concat(outputData).toString());
- _gthis.log(client1,errCodeMsg);
- }
- if(admins.indexOf(client) == -1) {
- _gthis.log(client,errCodeMsg);
- }
- return;
- }
- _gthis.add(outName);
- callback(outName);
- });
- };
- dlVideo.on("finish",function() {
- onComplete("Video");
- });
- dlAudio.on("finish",function() {
- onComplete("Audio");
- });
- dlVideo.on("progress",function(chunkLength,downloaded,contentLength) {
- var v = downloaded / contentLength;
- var ratio = v < 0 ? 0 : v > 1 ? 1 : v;
- _gthis.main.send(client,{ type : "Progress", progress : { type : "Downloading", ratio : ratio}});
- });
- }).catch(function(err) {
- _gthis.remove(inVideoName);
- _gthis.remove(inAudioName);
- _gthis.main.send(client,{ type : "Progress", progress : { type : "Canceled", ratio : 1}});
- _gthis.log(client,"" + err);
- });
- }
- ,setStorageLimit: function(bytes) {
- var _gthis = this;
- this.storageLimit = bytes;
- var a = this.storageLimit;
- this.storageLimit = a < 0 ? 0 : a;
- this.getFreeDiskSpace(function(availSpace) {
- var a = availSpace - _gthis.freeSpaceBlock;
- var availSpace = a < 0 ? 0 : a;
- _gthis.removeOlderCache();
- var freeSpace = _gthis.getFreeSpace();
- if(availSpace < freeSpace) {
- var a = _gthis.storageLimit += availSpace - freeSpace;
- _gthis.storageLimit = a < 0 ? 0 : a;
- _gthis.removeOlderCache();
- }
- });
- }
- ,getFreeDiskSpace: function(callback) {
- var _gthis = this;
- var tmp = js_node_Fs.statfs;
- if(tmp == null) {
- haxe_Log.trace("Warning: no fs.statfs support in current nodejs version (needs v18+)",{ fileName : "src/server/Cache.hx", lineNumber : 272, className : "server.Cache", methodName : "getFreeDiskSpace"});
- callback(this.storageLimit);
- return;
- }
- tmp("/",function(err,stats) {
- if(err != null) {
- haxe_Log.trace(err,{ fileName : "src/server/Cache.hx", lineNumber : 278, className : "server.Cache", methodName : "getFreeDiskSpace"});
- callback(_gthis.storageLimit);
- return;
- }
- callback(stats.bsize * stats.bavail);
- });
- }
- ,add: function(name) {
- if(this.cachedFiles.indexOf(name) == -1) {
- this.cachedFiles.unshift(name);
- }
- }
- ,remove: function(name) {
- HxOverrides.remove(this.cachedFiles,name);
- this.removeFile(name);
- }
- ,removeOlderCache: function(addFileSize) {
- if(addFileSize == null) {
- addFileSize = 0;
- }
- var space = this.getUsedSpace(addFileSize);
- while(space > this.storageLimit) {
- var tmp = this.cachedFiles.pop();
- if(tmp == null) {
- break;
- }
- this.removeFile(tmp);
- space = this.getUsedSpace(addFileSize);
- }
- return space < this.storageLimit;
- }
- ,removeFile: function(name) {
- var path = this.getFilePath(name);
- if(sys_FileSystem.exists(path)) {
- js_node_Fs.unlinkSync(path);
- }
- }
- ,getFreeFileName: function(fullName) {
- if(fullName == null) {
- fullName = "video.mp4";
- }
- var baseName = haxe_io_Path.withoutDirectory(haxe_io_Path.withoutExtension(fullName));
- var ext = haxe_io_Path.extension(fullName);
- var i = 1;
- while(true) {
- var name = "" + baseName + (i == 1 ? "" : "" + i) + "." + ext;
- if(!this.isFileExists(name)) {
- return name;
- }
- ++i;
- }
- }
- ,getFilePath: function(name) {
- return "" + this.cacheDir + "/" + name;
- }
- ,getFileUrl: function(name) {
- return "/" + haxe_io_Path.withoutDirectory(this.cacheDir) + "/" + name;
- }
- ,isFileExists: function(name) {
- return sys_FileSystem.exists(this.getFilePath(name));
- }
- ,getFreeSpace: function() {
- return this.storageLimit - this.getUsedSpace();
- }
- ,getUsedSpace: function(addFileSize) {
- if(addFileSize == null) {
- addFileSize = 0;
- }
- var total = addFileSize < 0 ? 0 : addFileSize;
- var arr = this.cachedFiles;
- var _g_i = arr.length - 1;
- while(_g_i > -1) {
- var name = arr[_g_i--];
- var path = this.getFilePath(name);
- if(!sys_FileSystem.exists(path)) {
- HxOverrides.remove(this.cachedFiles,name);
- continue;
- }
- total += js_node_Fs.statSync(path).size;
- }
- return total;
- }
- ,getBestYoutubeVideoFormat: function(formats,ignoreQuality) {
- var qPriority = [1080,720,480,360,240,144];
- var _g = 0;
- while(_g < qPriority.length) {
- var quality = "" + qPriority[_g++] + "p";
- if(quality == ignoreQuality) {
- continue;
- }
- var _g1 = 0;
- while(_g1 < formats.length) {
- var format = formats[_g1];
- ++_g1;
- if(format.videoCodec == null) {
- continue;
- }
- if(format.qualityLabel == quality) {
- return format;
- }
- }
- }
- return null;
- }
- ,__class__: server_Cache
-};
var server_ConsoleInput = function(main) {
var _g = new haxe_ds_StringMap();
_g.h["addAdmin"] = { args : ["name","password"], desc : "Adds channel admin"};
@@ -4165,7 +3758,7 @@ server_ConsoleInput.prototype = {
case "addAdmin":
var name = args[0];
var password = args[1];
- if(this.main.badNickName(name)) {
+ if(this.main.isBadClientName(name)) {
haxe_Log.trace(StringTools.replace(Lang.get("usernameError"),"$MAX","" + this.main.config.maxLoginLength),{ fileName : "src/server/ConsoleInput.hx", lineNumber : 113, className : "server.ConsoleInput", methodName : "parseLine"});
return;
}
@@ -4434,7 +4027,7 @@ server_HttpServer.prototype = {
}
});
stream.on("error",function(err) {
- haxe_Log.trace(err,{ fileName : "src/server/HttpServer.hx", lineNumber : 201, className : "server.HttpServer", methodName : "uploadFile"});
+ haxe_Log.trace(err,{ fileName : "src/server/HttpServer.hx", lineNumber : 202, className : "server.HttpServer", methodName : "uploadFile"});
res.statusCode = 500;
res.end(JSON.stringify({ info : "File write stream error."}));
var _this = _gthis.uploadingFilesSizes;
@@ -4448,7 +4041,7 @@ server_HttpServer.prototype = {
_gthis.cache.remove(name);
});
req.on("error",function(err) {
- haxe_Log.trace("Request Error:",{ fileName : "src/server/HttpServer.hx", lineNumber : 208, className : "server.HttpServer", methodName : "uploadFile", customParams : [err]});
+ haxe_Log.trace("Request Error:",{ fileName : "src/server/HttpServer.hx", lineNumber : 209, className : "server.HttpServer", methodName : "uploadFile", customParams : [err]});
stream.destroy();
res.statusCode = 500;
res.end(JSON.stringify({ info : "File request error."}));
@@ -4736,7 +4329,7 @@ var server_Main = function(opts) {
this.wsEventParser = new JsonParser_$1();
this.freeIds = [];
this.clients = [];
- this.playersCacheSupport = [];
+ this.playersCacheSupport = ["RawType"];
this.rootDir = "" + __dirname + "/..";
var _gthis = this;
this.isNoState = !opts.loadState;
@@ -4761,7 +4354,7 @@ var server_Main = function(opts) {
this.logger = new server_Logger(this.logsDir,10,this.verbose);
this.consoleInput = new server_ConsoleInput(this);
this.consoleInput.initConsoleInput();
- this.cache = new server_Cache(this,this.cacheDir);
+ this.cache = new server_cache_Cache(this,this.cacheDir);
if(this.cache.isYtReady) {
this.playersCacheSupport.push("YoutubeType");
}
@@ -4795,7 +4388,7 @@ var server_Main = function(opts) {
preparePort = function() {
server_Utils.isPortFree(_gthis.port,function(isFree) {
if(!isFree && attempts > 0) {
- haxe_Log.trace("Warning: port " + _gthis.port + " is already in use. Changed to " + (_gthis.port + 1),{ fileName : "src/server/Main.hx", lineNumber : 137, className : "server.Main", methodName : "new"});
+ haxe_Log.trace("Warning: port " + _gthis.port + " is already in use. Changed to " + (_gthis.port + 1),{ fileName : "src/server/Main.hx", lineNumber : 138, className : "server.Main", methodName : "new"});
attempts -= 1;
_gthis.port++;
preparePort();
@@ -4822,16 +4415,16 @@ server_Main.jsonFilterNulls = function(key,value) {
server_Main.prototype = {
runServer: function() {
var _gthis = this;
- haxe_Log.trace("Local: http://" + this.localIp + ":" + this.port,{ fileName : "src/server/Main.hx", lineNumber : 150, className : "server.Main", methodName : "runServer"});
+ haxe_Log.trace("Local: http://" + this.localIp + ":" + this.port,{ fileName : "src/server/Main.hx", lineNumber : 151, className : "server.Main", methodName : "runServer"});
if(this.config.localNetworkOnly) {
- haxe_Log.trace("Global network is disabled in config",{ fileName : "src/server/Main.hx", lineNumber : 152, className : "server.Main", methodName : "runServer"});
+ haxe_Log.trace("Global network is disabled in config",{ fileName : "src/server/Main.hx", lineNumber : 153, className : "server.Main", methodName : "runServer"});
} else if(!this.isNoState) {
server_Utils.getGlobalIp(function(ip) {
if(ip.indexOf(":") != -1) {
ip = "[" + ip + "]";
}
_gthis.globalIp = ip;
- haxe_Log.trace("Global: http://" + _gthis.globalIp + ":" + _gthis.port,{ fileName : "src/server/Main.hx", lineNumber : 158, className : "server.Main", methodName : "runServer"});
+ haxe_Log.trace("Global: http://" + _gthis.globalIp + ":" + _gthis.port,{ fileName : "src/server/Main.hx", lineNumber : 159, className : "server.Main", methodName : "runServer"});
});
}
var dir = "" + this.rootDir + "/res";
@@ -4916,7 +4509,7 @@ server_Main.prototype = {
var field = _g1[_g];
++_g;
if(Reflect.field(config,field) == null) {
- haxe_Log.trace("Warning: config field \"" + field + "\" is unknown",{ fileName : "src/server/Main.hx", lineNumber : 232, className : "server.Main", methodName : "getUserConfig"});
+ haxe_Log.trace("Warning: config field \"" + field + "\" is unknown",{ fileName : "src/server/Main.hx", lineNumber : 233, className : "server.Main", methodName : "getUserConfig"});
}
config[field] = Reflect.field(customConfig,field);
}
@@ -4927,14 +4520,14 @@ server_Main.prototype = {
var emote = _g1[_g];
++_g;
if(emoteCopies_h[emote.name]) {
- haxe_Log.trace("Warning: emote name \"" + emote.name + "\" has copy",{ fileName : "src/server/Main.hx", lineNumber : 238, className : "server.Main", methodName : "getUserConfig"});
+ haxe_Log.trace("Warning: emote name \"" + emote.name + "\" has copy",{ fileName : "src/server/Main.hx", lineNumber : 239, className : "server.Main", methodName : "getUserConfig"});
}
emoteCopies_h[emote.name] = true;
if(!this.verbose) {
continue;
}
if(emoteCopies_h[emote.image]) {
- haxe_Log.trace("Warning: emote url of name \"" + emote.name + "\" has copy",{ fileName : "src/server/Main.hx", lineNumber : 242, className : "server.Main", methodName : "getUserConfig"});
+ haxe_Log.trace("Warning: emote url of name \"" + emote.name + "\" has copy",{ fileName : "src/server/Main.hx", lineNumber : 243, className : "server.Main", methodName : "getUserConfig"});
}
emoteCopies_h[emote.image] = true;
}
@@ -4971,7 +4564,7 @@ server_Main.prototype = {
js_node_Fs.writeFileSync("" + this.userDir + "/users.json",JSON.stringify({ admins : users1, bans : _g, salt : users.salt},null,"\t"));
}
,saveState: function() {
- haxe_Log.trace("Saving state...",{ fileName : "src/server/Main.hx", lineNumber : 280, className : "server.Main", methodName : "saveState"});
+ haxe_Log.trace("Saving state...",{ fileName : "src/server/Main.hx", lineNumber : 281, className : "server.Main", methodName : "saveState"});
var json = JSON.stringify(this.getCurrentState(),null,"\t");
js_node_Fs.writeFileSync(this.statePath,json);
this.writeUsers(this.userList);
@@ -4986,7 +4579,7 @@ server_Main.prototype = {
if(!sys_FileSystem.exists(this.statePath)) {
return;
}
- haxe_Log.trace("Loading state...",{ fileName : "src/server/Main.hx", lineNumber : 304, className : "server.Main", methodName : "loadState"});
+ haxe_Log.trace("Loading state...",{ fileName : "src/server/Main.hx", lineNumber : 305, className : "server.Main", methodName : "loadState"});
var state = JSON.parse(js_node_Fs.readFileSync(this.statePath,{ encoding : "utf8"}));
state.flashbacks = state.flashbacks != null ? state.flashbacks : [];
state.cachedFiles = state.cachedFiles != null ? state.cachedFiles : [];
@@ -5008,7 +4601,7 @@ server_Main.prototype = {
}
,logError: function(type,data) {
this.cache.removeOlderCache(1048576);
- haxe_Log.trace(type,{ fileName : "src/server/Main.hx", lineNumber : 328, className : "server.Main", methodName : "logError", customParams : [data]});
+ haxe_Log.trace(type,{ fileName : "src/server/Main.hx", lineNumber : 329, className : "server.Main", methodName : "logError", customParams : [data]});
var crashesFolder = "" + this.userDir + "/crashes";
server_Utils.ensureDir(crashesFolder);
var name = DateTools.format(new Date(),"%Y-%m-%d_%H_%M_%S") + "-" + type;
@@ -5030,7 +4623,7 @@ server_Main.prototype = {
if(_gthis.clients.length == 0) {
return;
}
- haxe_Log.trace("Ping " + url,{ fileName : "src/server/Main.hx", lineNumber : 341, className : "server.Main", methodName : "initIntergationHandlers"});
+ haxe_Log.trace("Ping " + url,{ fileName : "src/server/Main.hx", lineNumber : 342, className : "server.Main", methodName : "initIntergationHandlers"});
js_node_Http.get(url,null,function(r) {
});
};
@@ -5049,13 +4642,13 @@ server_Main.prototype = {
password += this.config.salt;
var hash = haxe_crypto_Sha256.encode(password);
this.userList.admins.push({ name : name, hash : hash});
- haxe_Log.trace("Admin " + name + " added.",{ fileName : "src/server/Main.hx", lineNumber : 362, className : "server.Main", methodName : "addAdmin"});
+ haxe_Log.trace("Admin " + name + " added.",{ fileName : "src/server/Main.hx", lineNumber : 363, className : "server.Main", methodName : "addAdmin"});
}
,removeAdmin: function(name) {
HxOverrides.remove(this.userList.admins,Lambda.find(this.userList.admins,function(item) {
return item.name == name;
}));
- haxe_Log.trace("Admin " + name + " removed.",{ fileName : "src/server/Main.hx", lineNumber : 369, className : "server.Main", methodName : "removeAdmin"});
+ haxe_Log.trace("Admin " + name + " removed.",{ fileName : "src/server/Main.hx", lineNumber : 370, className : "server.Main", methodName : "removeAdmin"});
}
,replayLog: function(events) {
var _gthis = this;
@@ -5122,7 +4715,7 @@ server_Main.prototype = {
var ip = this.clientIp(req);
var id = this.freeIds.length > 0 ? this.freeIds.shift() : this.clients.length;
var name = "Guest " + (id + 1);
- haxe_Log.trace(HxOverrides.dateStr(new Date()),{ fileName : "src/server/Main.hx", lineNumber : 428, className : "server.Main", methodName : "onConnect", customParams : ["" + name + " connected (" + ip + ")"]});
+ haxe_Log.trace(HxOverrides.dateStr(new Date()),{ fileName : "src/server/Main.hx", lineNumber : 429, className : "server.Main", methodName : "onConnect", customParams : ["" + name + " connected (" + ip + ")"]});
var isAdmin = this.config.localAdmins && req.socket.localAddress == ip;
var client = new Client(ws,req,id,name,0);
client.uuid = uuid;
@@ -5136,7 +4729,7 @@ server_Main.prototype = {
var obj = _gthis.wsEventParser.fromJson(data.toString());
if(_gthis.wsEventParser.errors.length > 0 || _gthis.noTypeObj(obj)) {
var errors = "" + ("Wrong request for type \"" + obj.type + "\":") + "\n" + json2object_ErrorUtils.convertErrorArray(_gthis.wsEventParser.errors);
- haxe_Log.trace(errors,{ fileName : "src/server/Main.hx", lineNumber : 445, className : "server.Main", methodName : "onConnect"});
+ haxe_Log.trace(errors,{ fileName : "src/server/Main.hx", lineNumber : 446, className : "server.Main", methodName : "onConnect"});
_gthis.serverMessage(client,errors);
return;
}
@@ -5212,7 +4805,19 @@ server_Main.prototype = {
}
} else {
var _g = item.playerType;
- if(_g == "YoutubeType") {
+ switch(_g) {
+ case "RawType":
+ this.cache.cacheRawVideo(client,item.url,function(name) {
+ item = _$Types_VideoItemTools.withUrl(item,_gthis.cache.getFileUrl(name));
+ data.addVideo.item = item;
+ _gthis.videoList.addItem(item,data.addVideo.atEnd);
+ _gthis.broadcast(data);
+ if(_gthis.videoList.items.length == 1) {
+ _gthis.restartWaitTimer();
+ }
+ });
+ break;
+ case "YoutubeType":
this.cache.cacheYoutubeVideo(client,item.url,function(name) {
item = _$Types_VideoItemTools.withUrl(item,_gthis.cache.getFileUrl(name));
if(item.duration > 1) {
@@ -5225,7 +4830,8 @@ server_Main.prototype = {
_gthis.restartWaitTimer();
}
});
- } else {
+ break;
+ default:
var name = StringTools.replace("" + _g,"Type","");
this.serverMessage(client,"No cache support for " + name + " player.");
data.addVideo.item = item;
@@ -5318,7 +4924,7 @@ server_Main.prototype = {
if(!internal) {
return;
}
- haxe_Log.trace(HxOverrides.dateStr(new Date()),{ fileName : "src/server/Main.hx", lineNumber : 510, className : "server.Main", methodName : "onMessage", customParams : ["Client " + client.name + " disconnected"]});
+ haxe_Log.trace(HxOverrides.dateStr(new Date()),{ fileName : "src/server/Main.hx", lineNumber : 511, className : "server.Main", methodName : "onMessage", customParams : ["Client " + client.name + " disconnected"]});
server_Utils.sortedPush(this.freeIds,client.id);
HxOverrides.remove(this.clients,client);
this.sendClientList();
@@ -5432,7 +5038,7 @@ server_Main.prototype = {
case "Login":
var name = StringTools.trim(data.login.clientName);
var lcName = name.toLowerCase();
- if(this.badNickName(lcName)) {
+ if(this.isBadClientName(lcName)) {
this.serverMessage(client,"usernameError");
this.send(client,{ type : "LoginError"});
return;
@@ -5458,7 +5064,7 @@ server_Main.prototype = {
this.send(client,{ type : "LoginError"});
return;
}
- haxe_Log.trace(HxOverrides.dateStr(new Date()),{ fileName : "src/server/Main.hx", lineNumber : 601, className : "server.Main", methodName : "onMessage", customParams : ["Client " + client.name + " logged as " + name]});
+ haxe_Log.trace(HxOverrides.dateStr(new Date()),{ fileName : "src/server/Main.hx", lineNumber : 602, className : "server.Main", methodName : "onMessage", customParams : ["Client " + client.name + " logged as " + name]});
client.name = name;
client.setGroupFlag(ClientGroup.User,true);
this.checkBan(client);
@@ -5471,7 +5077,7 @@ server_Main.prototype = {
var oldName = client.name;
client.name = "Guest " + (this.clients.indexOf(client) + 1);
client.setGroupFlag(ClientGroup.User,false);
- haxe_Log.trace(HxOverrides.dateStr(new Date()),{ fileName : "src/server/Main.hx", lineNumber : 622, className : "server.Main", methodName : "onMessage", customParams : ["Client " + oldName + " logout to " + client.name]});
+ haxe_Log.trace(HxOverrides.dateStr(new Date()),{ fileName : "src/server/Main.hx", lineNumber : 623, className : "server.Main", methodName : "onMessage", customParams : ["Client " + oldName + " logout to " + client.name]});
this.send(client,{ type : data.type, logout : { oldClientName : oldName, clientName : client.name, clients : this.clientList()}});
this.sendClientListExcept(client);
break;
@@ -5801,13 +5407,13 @@ server_Main.prototype = {
client.setGroupFlag(ClientGroup.Banned,!isOutdated);
if(isOutdated) {
HxOverrides.remove(this.userList.bans,ban);
- haxe_Log.trace("" + client.name + " ban removed",{ fileName : "src/server/Main.hx", lineNumber : 1058, className : "server.Main", methodName : "checkBan"});
+ haxe_Log.trace("" + client.name + " ban removed",{ fileName : "src/server/Main.hx", lineNumber : 1064, className : "server.Main", methodName : "checkBan"});
this.sendClientList();
}
break;
}
}
- ,badNickName: function(name) {
+ ,isBadClientName: function(name) {
if(name.length > this.config.maxLoginLength) {
return true;
}
@@ -5907,6 +5513,11 @@ server_Main.prototype = {
}
return false;
}
+ ,hasPlaylistUrl: function(url) {
+ return this.videoList.exists(function(item) {
+ return item.url == url;
+ });
+ }
,__class__: server_Main
};
var server_Utils = function() { };
@@ -6110,6 +5721,705 @@ server_VideoTimer.prototype = {
}
,__class__: server_VideoTimer
};
+var server_cache_Cache = function(main,cacheDir) {
+ this.cachedFiles = [];
+ this.freeSpaceBlock = 10485760;
+ this.storageLimit = 3145728 * 1024;
+ this.isYtReady = false;
+ this.notEnoughSpaceErrorText = "Error: Not enough free space on server or file size is out of cache storage limit.";
+ this.main = main;
+ this.cacheDir = cacheDir;
+ server_Utils.ensureDir(cacheDir);
+ this.youtubeCache = new server_cache_YoutubeCache(main,this);
+ this.rawCache = new server_cache_RawCache(main,this);
+ this.isYtReady = this.youtubeCache.checkYtDeps();
+ if(this.isYtReady) {
+ this.youtubeCache.cleanYtInputFiles();
+ }
+};
+server_cache_Cache.__name__ = true;
+server_cache_Cache.prototype = {
+ getCachedFiles: function() {
+ return this.cachedFiles;
+ }
+ ,setCachedFiles: function(names) {
+ this.cachedFiles.length = 0;
+ var _g = 0;
+ while(_g < names.length) this.cachedFiles.push(names[_g++]);
+ var names = js_node_Fs.readdirSync(this.cacheDir);
+ var _g = 0;
+ while(_g < names.length) {
+ var name = names[_g];
+ ++_g;
+ if(StringTools.startsWith(name,".")) {
+ continue;
+ }
+ if(sys_FileSystem.isDirectory("" + this.cacheDir + "/" + name)) {
+ continue;
+ }
+ if(this.cachedFiles.indexOf(name) != -1) {
+ continue;
+ }
+ haxe_Log.trace("Remove non-tracked cache " + name,{ fileName : "src/server/cache/Cache.hx", lineNumber : 47, className : "server.cache.Cache", methodName : "setCachedFiles"});
+ this.remove(name);
+ }
+ }
+ ,log: function(client,msg) {
+ this.main.serverMessage(client,msg);
+ haxe_Log.trace(msg,{ fileName : "src/server/cache/Cache.hx", lineNumber : 54, className : "server.cache.Cache", methodName : "log"});
+ }
+ ,cacheYoutubeVideo: function(client,url,callback) {
+ this.youtubeCache.cacheYoutubeVideo(client,url,callback);
+ }
+ ,cacheRawVideo: function(client,url,callback) {
+ this.rawCache.cacheRawVideo(client,url,callback);
+ }
+ ,setStorageLimit: function(bytes) {
+ var _gthis = this;
+ this.storageLimit = bytes;
+ var a = this.storageLimit;
+ this.storageLimit = a < 0 ? 0 : a;
+ this.getFreeDiskSpace(function(availSpace) {
+ var a = availSpace - _gthis.freeSpaceBlock;
+ var availSpace = a < 0 ? 0 : a;
+ _gthis.removeOlderCache();
+ var freeSpace = _gthis.getFreeSpace();
+ if(availSpace < freeSpace) {
+ var a = _gthis.storageLimit += availSpace - freeSpace;
+ _gthis.storageLimit = a < 0 ? 0 : a;
+ _gthis.removeOlderCache();
+ }
+ });
+ }
+ ,getFreeDiskSpace: function(callback) {
+ var _gthis = this;
+ var tmp = js_node_Fs.statfs;
+ if(tmp == null) {
+ haxe_Log.trace("Warning: no fs.statfs support in current nodejs version (needs v18+)",{ fileName : "src/server/cache/Cache.hx", lineNumber : 83, className : "server.cache.Cache", methodName : "getFreeDiskSpace"});
+ callback(this.storageLimit);
+ return;
+ }
+ tmp("/",function(err,stats) {
+ if(err != null) {
+ haxe_Log.trace(err,{ fileName : "src/server/cache/Cache.hx", lineNumber : 89, className : "server.cache.Cache", methodName : "getFreeDiskSpace"});
+ callback(_gthis.storageLimit);
+ return;
+ }
+ callback(stats.bsize * stats.bavail);
+ });
+ }
+ ,add: function(name) {
+ if(this.cachedFiles.indexOf(name) == -1) {
+ this.cachedFiles.unshift(name);
+ }
+ }
+ ,remove: function(name) {
+ HxOverrides.remove(this.cachedFiles,name);
+ this.removeFile(name);
+ }
+ ,exists: function(name) {
+ if(this.cachedFiles.indexOf(name) != -1) {
+ return this.isFileExists(name);
+ } else {
+ return false;
+ }
+ }
+ ,removeOlderCache: function(addFileSize) {
+ if(addFileSize == null) {
+ addFileSize = 0;
+ }
+ var space = this.getUsedSpace(addFileSize);
+ var arr = this.cachedFiles;
+ var _g_i = arr.length - 1;
+ while(_g_i > -1) {
+ var name = arr[_g_i--];
+ if(space <= this.storageLimit) {
+ break;
+ }
+ if(this.main.hasPlaylistUrl(this.getFileUrl(name))) {
+ continue;
+ }
+ this.remove(name);
+ space = this.getUsedSpace(addFileSize);
+ }
+ return space < this.storageLimit;
+ }
+ ,removeFile: function(name) {
+ var path = this.getFilePath(name);
+ if(sys_FileSystem.exists(path)) {
+ js_node_Fs.unlinkSync(path);
+ }
+ }
+ ,getFreeFileName: function(fullName) {
+ if(fullName == null) {
+ fullName = "video.mp4";
+ }
+ var baseName = haxe_io_Path.withoutDirectory(haxe_io_Path.withoutExtension(fullName));
+ var ext = haxe_io_Path.extension(fullName);
+ var i = 1;
+ while(true) {
+ var name = "" + baseName + (i == 1 ? "" : "" + i) + "." + ext;
+ if(!this.isFileExists(name)) {
+ return name;
+ }
+ ++i;
+ }
+ }
+ ,getFilePath: function(name) {
+ return "" + this.cacheDir + "/" + name;
+ }
+ ,getFileUrl: function(name) {
+ return "/" + haxe_io_Path.withoutDirectory(this.cacheDir) + "/" + name;
+ }
+ ,isFileExists: function(name) {
+ return sys_FileSystem.exists(this.getFilePath(name));
+ }
+ ,getFreeSpace: function() {
+ return this.storageLimit - this.getUsedSpace();
+ }
+ ,getUsedSpace: function(addFileSize) {
+ if(addFileSize == null) {
+ addFileSize = 0;
+ }
+ var total = addFileSize < 0 ? 0 : addFileSize;
+ var arr = this.cachedFiles;
+ var _g_i = arr.length - 1;
+ while(_g_i > -1) {
+ var name = arr[_g_i--];
+ var path = this.getFilePath(name);
+ if(!sys_FileSystem.exists(path)) {
+ HxOverrides.remove(this.cachedFiles,name);
+ continue;
+ }
+ total += js_node_Fs.statSync(path).size;
+ }
+ return total;
+ }
+ ,__class__: server_cache_Cache
+};
+var server_cache_RawCache = function(main,cache) {
+ this.main = main;
+ this.cache = cache;
+};
+server_cache_RawCache.__name__ = true;
+server_cache_RawCache.prototype = {
+ cacheRawVideo: function(client,url,callback) {
+ var isM3U8 = url.indexOf(".m3u8") != -1;
+ var ext = isM3U8 ? "m3u8" : "mp4";
+ var matchName = new EReg("^([^:.]+)\\.(.+)","");
+ var decodedUrl;
+ try {
+ decodedUrl = decodeURIComponent(url.split("+").join(" "));
+ } catch( _g ) {
+ decodedUrl = url;
+ }
+ var outName = matchName.match(HxOverrides.substr(decodedUrl,decodedUrl.lastIndexOf("/") + 1,null)) ? matchName.matched(1) + ("." + ext) : "video." + ext;
+ outName = this.cache.getFreeFileName(outName);
+ if(this.cache.exists(outName)) {
+ callback(outName);
+ return;
+ }
+ haxe_Log.trace("Caching " + url + " to " + outName + "...",{ fileName : "src/server/cache/RawCache.hx", lineNumber : 46, className : "server.cache.RawCache", methodName : "cacheRawVideo"});
+ this.main.send(client,{ type : "Progress", progress : { type : "Caching", ratio : 0, data : outName}});
+ if(isM3U8) {
+ this.handleM3u8(client,url,outName,callback);
+ } else {
+ this.handleMp4(client,url,outName,callback);
+ }
+ }
+ ,handleMp4: function(client,url,outName,callback) {
+ var _gthis = this;
+ this.downloadFile(client,url,outName,function(downloaded,total) {
+ var v = downloaded / total;
+ _gthis.main.send(client,{ type : "Progress", progress : { type : "Downloading", ratio : v < 0 ? 0 : v > 1 ? 1 : v}});
+ },function() {
+ _gthis.cache.add(outName);
+ callback(outName);
+ },function(err) {
+ _gthis.log(client,"Mp4 download failed: " + err);
+ _gthis.cancelProgress(client);
+ });
+ }
+ ,handleM3u8: function(client,url,outName,callback) {
+ var _gthis = this;
+ var useProxy = true;
+ this.downloadM3u8Playlist(client,url,useProxy,function(playlist,totalSize,segments) {
+ if(useProxy) {
+ totalSize = playlist.length;
+ }
+ if(!_gthis.cache.removeOlderCache(totalSize + _gthis.cache.freeSpaceBlock)) {
+ _gthis.log(client,_gthis.cache.notEnoughSpaceErrorText);
+ _gthis.cancelProgress(client);
+ return;
+ }
+ if(useProxy) {
+ _gthis.main.send(client,{ type : "Progress", progress : { type : "Caching", ratio : 1, data : outName}});
+ js_node_Fs.writeFileSync("" + _gthis.cache.cacheDir + "/" + outName,playlist);
+ _gthis.cache.add(outName);
+ callback(outName);
+ return;
+ }
+ var activeDownloads = 0;
+ var maxParallelDownloads = 10;
+ var downloaded = 0;
+ var downloadNextBatch = null;
+ downloadNextBatch = function() {
+ var _g = 0;
+ while(_g < segments.length) {
+ var segment = [segments[_g]];
+ ++_g;
+ if(activeDownloads >= maxParallelDownloads) {
+ break;
+ }
+ if(segment[0].started) {
+ continue;
+ }
+ segment[0].started = true;
+ activeDownloads += 1;
+ haxe_Log.trace("download segment",{ fileName : "src/server/cache/RawCache.hx", lineNumber : 118, className : "server.cache.RawCache", methodName : "handleM3u8", customParams : [segment[0].i]});
+ _gthis.downloadFile(client,segment[0].url,segment[0].name,(function() {
+ return function(downloadedBytes,totalBytes) {
+ };
+ })(),(function(segment) {
+ return function() {
+ activeDownloads -= 1;
+ segment[0].completed = true;
+ downloaded += 1;
+ var progress = downloaded / segments.length;
+ _gthis.main.send(client,{ type : "Progress", progress : { type : "Downloading", ratio : progress < 0 ? 0 : progress > 1 ? 1 : progress}});
+ if(downloaded == segments.length) {
+ haxe_Log.trace("All " + downloaded + "/" + segments.length + " segments downloaded",{ fileName : "src/server/cache/RawCache.hx", lineNumber : 138, className : "server.cache.RawCache", methodName : "handleM3u8"});
+ js_node_Fs.writeFileSync("" + _gthis.cache.cacheDir + "/" + outName,playlist);
+ _gthis.cache.add(outName);
+ callback(outName);
+ } else {
+ downloadNextBatch();
+ }
+ };
+ })(segment),(function(segment) {
+ return function(err) {
+ activeDownloads -= 1;
+ downloaded += 1;
+ _gthis.log(client,"TS segment " + segment[0].i + " download failed: " + err);
+ _gthis.cancelProgress(client);
+ var _gthis1 = _gthis;
+ var result = new Array(segments.length);
+ var _g = 0;
+ var _g1 = segments.length;
+ while(_g < _g1) {
+ var i = _g++;
+ result[i] = segments[i].name;
+ }
+ _gthis1.cleanupFiles(result);
+ };
+ })(segment));
+ }
+ };
+ downloadNextBatch();
+ },function(err) {
+ _gthis.log(client,"M3U8 processing failed: " + err);
+ _gthis.cancelProgress(client);
+ });
+ }
+ ,request: function(url,options,callback) {
+ var httpsOptions = options != null ? options : { };
+ httpsOptions.rejectUnauthorized = false;
+ httpsOptions.headers = httpsOptions.headers != null ? httpsOptions.headers : { };
+ if(StringTools.startsWith(url,"https:")) {
+ return js_node_Https.request(url,httpsOptions,callback);
+ } else {
+ return js_node_Http.request(url,httpsOptions,callback);
+ }
+ }
+ ,downloadM3u8Playlist: function(client,url,useProxy,onSuccess,onError) {
+ var _gthis = this;
+ var req = this.request(url,{ headers : { "User-Agent" : "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36", "Accept" : "*/*"}},function(res) {
+ if(res.statusCode >= 300 && res.statusCode < 400) {
+ var redirectUrl = res.headers["location"];
+ if(redirectUrl != null) {
+ _gthis.downloadM3u8Playlist(client,redirectUrl,useProxy,onSuccess,onError);
+ return;
+ }
+ }
+ var body = [];
+ res.on("data",function(chunk) {
+ return body.push(chunk);
+ });
+ res.on("end",function() {
+ try {
+ var buffer = js_node_buffer_Buffer.concat(body);
+ var content = buffer.toString();
+ if(!new EReg("^#EXTM3U","").match(content)) {
+ onError("Invalid M3U8 playlist");
+ return;
+ }
+ var baseUrl = url.substring(0,url.lastIndexOf("/") + 1);
+ var segments = [];
+ var lines = content.split("\n");
+ var _g_current = 0;
+ var _g_array = lines;
+ while(_g_current < _g_array.length) {
+ var _g_value = _g_array[_g_current++];
+ var _g_key = _g_current - 1;
+ var line = StringTools.trim(_g_value);
+ if(line.length == 0) {
+ continue;
+ }
+ if(StringTools.startsWith(line,"#")) {
+ continue;
+ }
+ var segmentUrl = line.indexOf("://") == -1 ? baseUrl + line : line;
+ var i = segments.length;
+ var segment = { i : i, url : segmentUrl, started : false, completed : false, name : "segment" + i + ".ts"};
+ segments.push(segment);
+ lines[_g_key] = "./" + segment.name;
+ if(useProxy) {
+ lines[_g_key] = "/proxy?url=" + segmentUrl;
+ }
+ }
+ var req = _gthis.request(segments[0].url,{ method : "GET"},function(res) {
+ var tmp = Std.parseInt(res.headers["content-length"]);
+ var totalSize = (tmp != null ? tmp : 0) * (segments.length + 1);
+ if(totalSize == 0) {
+ onError("Failed to get segment sizes: no content-length");
+ return;
+ }
+ onSuccess(lines.join("\n"),totalSize,segments);
+ });
+ req.on("error",function(err) {
+ onError("Request error: failed to get segment sizes");
+ });
+ req.end();
+ } catch( _g ) {
+ var _g1 = haxe_Exception.caught(_g);
+ onError("Playlist processing error: " + Std.string(_g1));
+ }
+ });
+ });
+ req.on("error",onError);
+ req.end();
+ }
+ ,downloadFile: function(client,url,fileName,onProgress,onComplete,onError) {
+ var file = js_node_Fs.createWriteStream("" + this.cache.cacheDir + "/" + fileName);
+ var req = this.request(url,{ method : "GET", headers : { "User-Agent" : "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537." + Std.random(100), "Accept" : "*/*"}},function(res) {
+ var total;
+ var tmp = Std.parseInt(res.headers["content-length"]);
+ total = tmp != null ? tmp : 0;
+ var downloaded = 0;
+ res.on("data",function(chunk) {
+ downloaded += chunk.length;
+ onProgress(downloaded,total);
+ if(!file.write(chunk)) {
+ res.pause();
+ file.once("drain",function() {
+ return res.resume();
+ });
+ }
+ });
+ res.on("end",function() {
+ file.end();
+ });
+ res.on("error",function(err) {
+ file.destroy();
+ onError("Response error: " + err);
+ });
+ });
+ file.on("finish",onComplete);
+ file.on("error",function(err) {
+ req.destroy();
+ onError("File error: " + err);
+ });
+ req.on("error",function(err) {
+ file.destroy();
+ onError("Request failed: " + err);
+ });
+ req.end();
+ }
+ ,cleanupFiles: function(files) {
+ var _g = 0;
+ while(_g < files.length) {
+ var file = files[_g];
+ ++_g;
+ if(sys_FileSystem.exists(file)) {
+ js_node_Fs.unlinkSync(file);
+ }
+ }
+ }
+ ,log: function(client,msg) {
+ this.cache.log(client,msg);
+ }
+ ,cancelProgress: function(client) {
+ this.main.send(client,{ type : "Progress", progress : { type : "Canceled", ratio : 0}});
+ }
+ ,__class__: server_cache_RawCache
+};
+var server_cache_YoutubeCache = function(main,cache) {
+ this.main = main;
+ this.cache = cache;
+};
+server_cache_YoutubeCache.__name__ = true;
+server_cache_YoutubeCache.prototype = {
+ checkYtDeps: function() {
+ var ytdl;
+ try {
+ ytdl = require("@distube/ytdl-core");
+ } catch( _g ) {
+ return false;
+ }
+ try {
+ js_node_ChildProcess.execSync("ffmpeg -version",{ stdio : "ignore", timeout : 3000});
+ return true;
+ } catch( _g ) {
+ return false;
+ }
+ }
+ ,cleanYtInputFiles: function() {
+ var names = js_node_Fs.readdirSync(this.cache.cacheDir);
+ var _g = 0;
+ while(_g < names.length) {
+ var name = names[_g];
+ ++_g;
+ if(!StringTools.startsWith(name,"__tmp")) {
+ continue;
+ }
+ this.cache.remove(name);
+ }
+ }
+ ,cacheYoutubeVideo: function(client,url,callback) {
+ var _gthis = this;
+ if(!this.cache.isYtReady) {
+ haxe_Log.trace("Do `npm i @distube/ytdl-core@latest` to use cache feature (you also need to install `ffmpeg` to build mp4 from downloaded audio/video tracks).",{ fileName : "src/server/cache/YoutubeCache.hx", lineNumber : 46, className : "server.cache.YoutubeCache", methodName : "cacheYoutubeVideo"});
+ return;
+ }
+ var videoId = utils_YoutubeUtils.extractVideoId(url);
+ if(videoId == "") {
+ this.log(client,"Error: youtube video id not found in url: " + url);
+ return;
+ }
+ var outName = videoId + ".mp4";
+ if(this.cache.exists(outName)) {
+ callback(outName);
+ return;
+ }
+ var inVideoName = "__tmp-video-" + videoId;
+ var inAudioName = "__tmp-audio-" + videoId;
+ if(this.cache.isFileExists(inVideoName)) {
+ this.log(client,"Caching " + outName + " already in progress");
+ return;
+ }
+ var ytdl = require("@distube/ytdl-core");
+ haxe_Log.trace("Caching " + url + " to " + outName + "...",{ fileName : "src/server/cache/YoutubeCache.hx", lineNumber : 80, className : "server.cache.YoutubeCache", methodName : "cacheYoutubeVideo"});
+ this.main.send(client,{ type : "Progress", progress : { type : "Caching", ratio : 0, data : outName}});
+ var agent = null;
+ var cookiesPath = "" + this.main.userDir + "/cookies.json";
+ if(sys_FileSystem.exists(cookiesPath)) {
+ agent = ytdl.createAgent(JSON.parse(js_node_Fs.readFileSync(cookiesPath,{ encoding : "utf8"})));
+ }
+ var promise = ytdl.getInfo(url,{ agent : agent});
+ promise.then(function(info) {
+ haxe_Log.trace("Get info with " + info.formats.length + " formats",{ fileName : "src/server/cache/YoutubeCache.hx", lineNumber : 98, className : "server.cache.YoutubeCache", methodName : "cacheYoutubeVideo"});
+ var audioFormat;
+ try {
+ var ytdl1 = ytdl.chooseFormat;
+ var _g = [];
+ var _g1 = 0;
+ var _g2 = info.formats;
+ while(_g1 < _g2.length) {
+ var v = _g2[_g1];
+ ++_g1;
+ var tmp = v.audioCodec;
+ if(tmp != null ? StringTools.startsWith(tmp,"mp4a") : null) {
+ _g.push(v);
+ }
+ }
+ audioFormat = ytdl1(_g,{ quality : "highestaudio"});
+ } catch( _g ) {
+ var e = haxe_Exception.caught(_g);
+ _gthis.log(client,"Error: audio format not found");
+ haxe_Log.trace(e,{ fileName : "src/server/cache/YoutubeCache.hx", lineNumber : 105, className : "server.cache.YoutubeCache", methodName : "cacheYoutubeVideo"});
+ var _g1 = [];
+ var _g2 = 0;
+ var _g3 = info.formats;
+ while(_g2 < _g3.length) {
+ var v = _g3[_g2];
+ ++_g2;
+ if(v.hasAudio) {
+ _g1.push(v);
+ }
+ }
+ haxe_Log.trace(_g1,{ fileName : "src/server/cache/YoutubeCache.hx", lineNumber : 106, className : "server.cache.YoutubeCache", methodName : "cacheYoutubeVideo"});
+ return;
+ }
+ var videoFormat;
+ var tmp = _gthis.getBestYoutubeVideoFormat(info.formats);
+ if(tmp != null) {
+ videoFormat = tmp;
+ } else {
+ _gthis.log(client,"Error: video format not found");
+ var _g = [];
+ var _g1 = 0;
+ var _g2 = info.formats;
+ while(_g1 < _g2.length) {
+ var v = _g2[_g1];
+ ++_g1;
+ if(v.hasVideo) {
+ _g.push(v);
+ }
+ }
+ haxe_Log.trace(_g,{ fileName : "src/server/cache/YoutubeCache.hx", lineNumber : 111, className : "server.cache.YoutubeCache", methodName : "cacheYoutubeVideo"});
+ return;
+ }
+ var tmp = Std.parseInt(videoFormat.contentLength);
+ var videoSize = tmp != null ? tmp : 0;
+ var tmp = Std.parseInt(audioFormat.contentLength);
+ var audioSize = tmp != null ? tmp : 0;
+ var hasSpace = _gthis.cache.removeOlderCache((videoSize + audioSize) * 2 + _gthis.cache.freeSpaceBlock);
+ if(!hasSpace) {
+ videoFormat = _gthis.getBestYoutubeVideoFormat(info.formats,videoFormat.qualityLabel);
+ var tmp = Std.parseInt(videoFormat.contentLength);
+ var videoSize = tmp != null ? tmp : 0;
+ var tmp = Std.parseInt(audioFormat.contentLength);
+ var audioSize = tmp != null ? tmp : 0;
+ var hasSpace = _gthis.cache.removeOlderCache((videoSize + audioSize) * 2 + _gthis.cache.freeSpaceBlock);
+ if(!hasSpace) {
+ _gthis.cache.remove(inVideoName);
+ _gthis.cache.remove(inAudioName);
+ _gthis.cancelProgress(client);
+ _gthis.log(client,_gthis.cache.notEnoughSpaceErrorText);
+ }
+ if(!hasSpace) {
+ return;
+ }
+ }
+ var dlVideo = ytdl(url,{ format : videoFormat, agent : agent});
+ dlVideo.pipe(js_node_Fs.createWriteStream("" + _gthis.cache.cacheDir + "/" + inVideoName));
+ dlVideo.on("error",function(err) {
+ _gthis.log(client,"Error during video download: " + err);
+ _gthis.cache.remove(inVideoName);
+ _gthis.cache.remove(inAudioName);
+ _gthis.cancelProgress(client);
+ });
+ var dlAudio = ytdl(url,{ format : audioFormat, agent : agent});
+ dlAudio.pipe(js_node_Fs.createWriteStream("" + _gthis.cache.cacheDir + "/" + inAudioName));
+ dlAudio.on("error",function(err) {
+ _gthis.log(client,"Error during audio download: " + err);
+ _gthis.cache.remove(inVideoName);
+ _gthis.cache.remove(inAudioName);
+ _gthis.cancelProgress(client);
+ });
+ var count = 0;
+ var onComplete = function(type) {
+ count += 1;
+ haxe_Log.trace("" + type + " track downloaded (" + count + "/2)",{ fileName : "src/server/cache/YoutubeCache.hx", lineNumber : 153, className : "server.cache.YoutubeCache", methodName : "cacheYoutubeVideo"});
+ if(count < 2) {
+ return;
+ }
+ if(!_gthis.cache.isFileExists(inVideoName) || !_gthis.cache.isFileExists(inAudioName)) {
+ _gthis.log(client,"Input files not found for making final video");
+ _gthis.cache.remove(inVideoName);
+ _gthis.cache.remove(inAudioName);
+ _gthis.cancelProgress(client);
+ return;
+ }
+ var size = js_node_Fs.statSync("" + _gthis.cache.cacheDir + "/" + inVideoName).size;
+ size += js_node_Fs.statSync("" + _gthis.cache.cacheDir + "/" + inAudioName).size;
+ var hasSpace = _gthis.cache.removeOlderCache(size + _gthis.cache.freeSpaceBlock);
+ if(!hasSpace) {
+ _gthis.cache.remove(inVideoName);
+ _gthis.cache.remove(inAudioName);
+ _gthis.cancelProgress(client);
+ _gthis.log(client,_gthis.cache.notEnoughSpaceErrorText);
+ }
+ if(!hasSpace) {
+ return;
+ }
+ var args = ("-y -i ./" + inVideoName + " -i ./" + inAudioName + " -c copy -map 0:v -map 1:a ./" + outName).split(" ");
+ var $process = js_node_ChildProcess.spawn("ffmpeg",args,{ cwd : _gthis.cache.cacheDir});
+ var outputData = [];
+ $process.stderr.on("data",function(data) {
+ return outputData.push(data);
+ });
+ $process.on("close",function(code) {
+ _gthis.cache.remove(inVideoName);
+ _gthis.cache.remove(inAudioName);
+ if(code != 0) {
+ _gthis.cancelProgress(client);
+ var errCodeMsg = "Error: ffmpeg closed with code " + code;
+ var _g = [];
+ var _g1 = 0;
+ var _g2 = _gthis.main.clients;
+ while(_g1 < _g2.length) {
+ var v = _g2[_g1];
+ ++_g1;
+ if((v.group & 1 << ClientGroup.Admin._hx_index) != 0) {
+ _g.push(v);
+ }
+ }
+ var admins = _g;
+ var _g = 0;
+ while(_g < admins.length) {
+ var client1 = admins[_g];
+ ++_g;
+ _gthis.log(client1,js_node_buffer_Buffer.concat(outputData).toString());
+ _gthis.log(client1,errCodeMsg);
+ }
+ if(admins.indexOf(client) == -1) {
+ _gthis.log(client,errCodeMsg);
+ }
+ return;
+ }
+ _gthis.cache.add(outName);
+ callback(outName);
+ });
+ };
+ dlVideo.on("finish",function() {
+ onComplete("Video");
+ });
+ dlAudio.on("finish",function() {
+ onComplete("Audio");
+ });
+ dlVideo.on("progress",function(chunkLength,downloaded,contentLength) {
+ var v = downloaded / contentLength;
+ var ratio = v < 0 ? 0 : v > 1 ? 1 : v;
+ _gthis.main.send(client,{ type : "Progress", progress : { type : "Downloading", ratio : ratio}});
+ });
+ }).catch(function(err) {
+ _gthis.cache.remove(inVideoName);
+ _gthis.cache.remove(inAudioName);
+ _gthis.cancelProgress(client);
+ _gthis.log(client,"" + err);
+ });
+ }
+ ,getBestYoutubeVideoFormat: function(formats,ignoreQuality) {
+ var qPriority = [1080,720,480,360,240,144];
+ var _g = 0;
+ while(_g < qPriority.length) {
+ var quality = "" + qPriority[_g++] + "p";
+ if(quality == ignoreQuality) {
+ continue;
+ }
+ var _g1 = 0;
+ while(_g1 < formats.length) {
+ var format = formats[_g1];
+ ++_g1;
+ if(format.videoCodec == null) {
+ continue;
+ }
+ if(format.qualityLabel == quality) {
+ return format;
+ }
+ }
+ }
+ return null;
+ }
+ ,log: function(client,msg) {
+ this.cache.log(client,msg);
+ }
+ ,cancelProgress: function(client) {
+ this.main.send(client,{ type : "Progress", progress : { type : "Canceled", ratio : 0}});
+ }
+ ,__class__: server_cache_YoutubeCache
+};
var sys_FileSystem = function() { };
sys_FileSystem.__name__ = true;
sys_FileSystem.exists = function(path) {
diff --git a/res/client.js b/res/client.js
index eb2d313..cdfee99 100644
--- a/res/client.js
+++ b/res/client.js
@@ -242,6 +242,17 @@ Lambda.find = function(it,f) {
}
return null;
};
+Lambda.findIndex = function(it,f) {
+ var i = 0;
+ var v = $getIterator(it);
+ while(v.hasNext()) {
+ if(f(v.next())) {
+ return i;
+ }
+ ++i;
+ }
+ return -1;
+};
var Lang = function() { };
Lang.__name__ = true;
Lang.request = function(path,callback) {
@@ -327,6 +338,13 @@ Reflect.fields = function(o) {
}
return a;
};
+Reflect.deleteField = function(o,field) {
+ if(!Object.prototype.hasOwnProperty.call(o,field)) {
+ return false;
+ }
+ delete(o[field]);
+ return true;
+};
Reflect.copy = function(o) {
if(o == null) {
return null;
@@ -499,16 +517,7 @@ VideoList.prototype = {
this.pos = i;
}
,findIndex: function(f) {
- var i = 0;
- var _g = 0;
- var _g1 = this.items;
- while(_g < _g1.length) {
- if(f(_g1[_g++])) {
- return i;
- }
- ++i;
- }
- return -1;
+ return Lambda.findIndex(this.items,f);
}
,addItem: function(item,atEnd) {
if(atEnd) {
@@ -768,6 +777,7 @@ client_Buttons.init = function(main) {
}
};
var mediaUrl = window.document.querySelector("#mediaurl");
+ var checkboxCache = window.document.querySelector("#cache-on-server");
mediaUrl.oninput = function() {
var url = mediaUrl.value;
var playerType = main.getLinkPlayerType(url);
@@ -777,8 +787,9 @@ client_Buttons.init = function(main) {
window.document.querySelector("#subsurlblock").style.display = isSingleRawVideo ? "" : "none";
var tmp = url.length > 0 && isSingle;
window.document.querySelector("#voiceoverblock").style.display = tmp ? "" : "none";
- var tmp = isSingle && main.playersCacheSupport.indexOf(playerType) != -1 ? "" : "none";
- window.document.querySelector("#cache-on-server").parentElement.style.display = tmp;
+ var isExternal = main.isExternalVideoUrl(url);
+ checkboxCache.parentElement.style.display = isSingle && isExternal && main.playersCacheSupport.indexOf(playerType) != -1 ? "" : "none";
+ checkboxCache.checked = client_Buttons.settings.checkedCache.indexOf(playerType) != -1;
var panel = window.document.querySelector("#addfromurl");
var oldH = panel.style.height;
panel.style.height = "";
@@ -789,6 +800,16 @@ client_Buttons.init = function(main) {
},0);
};
mediaUrl.onfocus = mediaUrl.oninput;
+ checkboxCache.addEventListener("change",function() {
+ var url = mediaUrl.value;
+ var playerType = main.getLinkPlayerType(url);
+ var checked = checkboxCache.checked;
+ HxOverrides.remove(client_Buttons.settings.checkedCache,playerType);
+ if(checked) {
+ client_Buttons.settings.checkedCache.push(playerType);
+ }
+ client_Settings.write(client_Buttons.settings);
+ });
window.document.querySelector("#insert_template").onclick = function(e) {
mediaUrl.value = main.getTemplateUrl();
mediaUrl.focus();
@@ -832,7 +853,7 @@ client_Buttons.init = function(main) {
try {
data = JSON.parse(request.responseText);
} catch( _g ) {
- haxe_Log.trace(haxe_Exception.caught(_g),{ fileName : "src/client/Buttons.hx", lineNumber : 300, className : "client.Buttons", methodName : "init"});
+ haxe_Log.trace(haxe_Exception.caught(_g),{ fileName : "src/client/Buttons.hx", lineNumber : 316, className : "client.Buttons", methodName : "init"});
return;
}
if(data.errorId == null) {
@@ -1070,7 +1091,7 @@ client_Buttons.initChatInputs = function(main) {
}
return true;
});
- var checkboxes = [window.document.querySelector("#add-temp"),window.document.querySelector("#cache-on-server")];
+ var checkboxes = [window.document.querySelector("#add-temp")];
var _g = 0;
while(_g < checkboxes.length) {
var checkbox = [checkboxes[_g]];
@@ -1352,7 +1373,7 @@ var client_Main = function() {
if(this.host == "") {
this.host = "localhost";
}
- client_Settings.init({ version : 5, uuid : null, name : "", hash : "", isExtendedPlayer : false, playerSize : 1, chatSize : 300, synchThreshold : 2, isSwapped : false, isUserListHidden : true, latestLinks : [], latestSubs : [], hotkeysEnabled : true, showHintList : true, checkboxes : []},$bind(this,this.settingsPatcher));
+ client_Settings.init({ version : 6, uuid : null, name : "", hash : "", chatSize : 300, synchThreshold : 2, isSwapped : false, isUserListHidden : true, latestLinks : [], latestSubs : [], hotkeysEnabled : true, showHintList : true, checkboxes : [], checkedCache : []},$bind(this,this.settingsPatcher));
this.settings = client_Settings.read();
this.initListeners();
this.onTimeGet = new haxe_Timer(this.settings.synchThreshold * 1000);
@@ -1406,6 +1427,19 @@ client_Main.prototype = {
data.checkboxes = [];
break;
case 5:
+ var data1 = data;
+ data1.checkedCache = [];
+ Reflect.deleteField(data1,"playerSize");
+ Reflect.deleteField(data1,"isExtendedPlayer");
+ var oldCheck = Lambda.find(data1.checkboxes,function(item) {
+ return item.id == "cache-on-server";
+ });
+ if(oldCheck != null) {
+ HxOverrides.remove(data1.checkboxes,oldCheck);
+ data1.checkedCache.push("YoutubeType");
+ }
+ break;
+ case 6:
throw haxe_Exception.thrown("skipped version " + version);
default:
throw haxe_Exception.thrown("skipped version " + version);
@@ -1573,6 +1607,17 @@ client_Main.prototype = {
,isSingleVideoUrl: function(url) {
return this.player.isSingleVideoUrl(url);
}
+ ,isExternalVideoUrl: function(url) {
+ url = StringTools.ltrim(url);
+ if(StringTools.startsWith(url,"/")) {
+ return false;
+ }
+ var host = $global.location.hostname;
+ if(url.indexOf(host) != -1) {
+ return false;
+ }
+ return true;
+ }
,sortItemsForQueueNext: function(items) {
if(items.length == 0) {
return;
@@ -1684,7 +1729,7 @@ client_Main.prototype = {
var data = JSON.parse(e.data);
if(this.config != null && this.config.isVerbose) {
var t = data.type;
- haxe_Log.trace("Event: " + data.type,{ fileName : "src/client/Main.hx", lineNumber : 460, className : "client.Main", methodName : "onMessage", customParams : [Reflect.field(data,t.charAt(0).toLowerCase() + HxOverrides.substr(t,1,null))]});
+ haxe_Log.trace("Event: " + data.type,{ fileName : "src/client/Main.hx", lineNumber : 477, className : "client.Main", methodName : "onMessage", customParams : [Reflect.field(data,t.charAt(0).toLowerCase() + HxOverrides.substr(t,1,null))]});
}
client_JsApi.fireEvents(data);
switch(data.type) {
diff --git a/src/VideoList.hx b/src/VideoList.hx
index eb3e67d..852c5e5 100644
--- a/src/VideoList.hx
+++ b/src/VideoList.hx
@@ -52,13 +52,12 @@ class VideoList {
return items.exists(f);
}
+ public function find(f:(item:VideoItem) -> Bool):Null<VideoItem> {
+ return items.find(f);
+ }
+
public function findIndex(f:(item:VideoItem) -> Bool):Int {
- var i = 0;
- for (v in items) {
- if (f(v)) return i;
- i++;
- }
- return -1;
+ return items.findIndex(f);
}
public function addItem(item:VideoItem, atEnd:Bool):Void {
diff --git a/src/client/Buttons.hx b/src/client/Buttons.hx
index 11b8f7c..78b1c5a 100644
--- a/src/client/Buttons.hx
+++ b/src/client/Buttons.hx
@@ -222,6 +222,7 @@ class Buttons {
}
final mediaUrl:InputElement = getEl("#mediaurl");
+ final checkboxCache:InputElement = getEl("#cache-on-server");
mediaUrl.oninput = () -> {
final url = mediaUrl.value;
final playerType = main.getLinkPlayerType(url);
@@ -230,8 +231,13 @@ class Buttons {
getEl("#mediatitleblock").style.display = isSingleRawVideo ? "" : "none";
getEl("#subsurlblock").style.display = isSingleRawVideo ? "" : "none";
getEl("#voiceoverblock").style.display = (url.length > 0 && isSingle) ? "" : "none";
- final showCache = isSingle && main.playersCacheSupport.contains(playerType);
- getEl("#cache-on-server").parentElement.style.display = showCache ? "" : "none";
+
+ final isExternal = main.isExternalVideoUrl(url);
+ final showCache = isSingle && isExternal
+ && main.playersCacheSupport.contains(playerType);
+ checkboxCache.parentElement.style.display = showCache ? "" : "none";
+ checkboxCache.checked = settings.checkedCache.contains(playerType);
+
final panel = getEl("#addfromurl");
final oldH = panel.style.height; // save for animation
panel.style.height = ""; // to calculate height from content
@@ -241,6 +247,16 @@ class Buttons {
}
mediaUrl.onfocus = mediaUrl.oninput;
+ checkboxCache.addEventListener("change", () -> {
+ final url = mediaUrl.value;
+ final playerType = main.getLinkPlayerType(url);
+ final checked = checkboxCache.checked;
+
+ settings.checkedCache.remove(playerType);
+ if (checked) settings.checkedCache.push(playerType);
+ Settings.write(settings);
+ });
+
getEl("#insert_template").onclick = e -> {
mediaUrl.value = main.getTemplateUrl();
mediaUrl.focus();
@@ -518,7 +534,6 @@ class Buttons {
});
final checkboxes:Array<InputElement> = [
getEl("#add-temp"),
- getEl("#cache-on-server"),
];
for (checkbox in checkboxes) {
checkbox.addEventListener("change", () -> {
diff --git a/src/client/ClientSettings.hx b/src/client/ClientSettings.hx
index cb5f99f..1bca427 100644
--- a/src/client/ClientSettings.hx
+++ b/src/client/ClientSettings.hx
@@ -1,19 +1,20 @@
package client;
+import Types.PlayerType;
+
typedef ClientSettings = {
- version:Int,
- uuid:Null<String>,
- name:String,
- hash:String,
- isExtendedPlayer:Bool,
- playerSize:Float,
- chatSize:Float,
- synchThreshold:Int,
- isSwapped:Bool,
- isUserListHidden:Bool,
- latestLinks:Array<String>,
- latestSubs:Array<String>,
- hotkeysEnabled:Bool,
- showHintList:Bool,
- checkboxes:Array<{id:String, checked:Null<Bool>}>,
+ var version:Int;
+ var uuid:Null<String>;
+ var name:String;
+ var hash:String;
+ var chatSize:Float;
+ var synchThreshold:Int;
+ var isSwapped:Bool;
+ var isUserListHidden:Bool;
+ var latestLinks:Array<String>;
+ var latestSubs:Array<String>;
+ var hotkeysEnabled:Bool;
+ var showHintList:Bool;
+ var checkboxes:Array<{id:String, checked:Null<Bool>}>;
+ var checkedCache:Array<PlayerType>;
}
diff --git a/src/client/Main.hx b/src/client/Main.hx
index e7f8f30..abc39f6 100644
--- a/src/client/Main.hx
+++ b/src/client/Main.hx
@@ -27,7 +27,7 @@ import js.html.WebSocket;
class Main {
public static var instance(default, null):Main;
- static inline var SETTINGS_VERSION = 5;
+ static inline var SETTINGS_VERSION = 6;
static inline var MAX_CHAT_MESSAGES = 200;
public final settings:ClientSettings;
@@ -81,8 +81,6 @@ class Main {
uuid: null,
name: "",
hash: "",
- isExtendedPlayer: false,
- playerSize: 1,
chatSize: 300,
synchThreshold: 2,
isSwapped: false,
@@ -92,6 +90,7 @@ class Main {
hotkeysEnabled: true,
showHintList: true,
checkboxes: [],
+ checkedCache: [],
}
Settings.init(defaults, settingsPatcher);
settings = Settings.read();
@@ -139,6 +138,16 @@ class Main {
case 4:
final data:ClientSettings = data;
data.checkboxes = [];
+ case 5:
+ final data:ClientSettings = data;
+ data.checkedCache = [];
+ Reflect.deleteField(data, "playerSize");
+ Reflect.deleteField(data, "isExtendedPlayer");
+ final oldCheck = data.checkboxes.find(item -> item.id == "cache-on-server");
+ if (oldCheck != null) {
+ data.checkboxes.remove(oldCheck);
+ data.checkedCache.push(YoutubeType);
+ }
case SETTINGS_VERSION, _:
throw 'skipped version $version';
}
@@ -307,6 +316,14 @@ class Main {
return player.isSingleVideoUrl(url);
}
+ public function isExternalVideoUrl(url:String):Bool {
+ url = url.ltrim();
+ if (url.startsWith("/")) return false;
+ final host = Browser.location.hostname;
+ if (url.contains(host)) return false;
+ return true;
+ }
+
public function sortItemsForQueueNext<T>(items:Array<T>):Void {
if (items.length == 0) return;
// except first item when list empty
diff --git a/src/server/ConsoleInput.hx b/src/server/ConsoleInput.hx
index cf88df6..e0712f5 100644
--- a/src/server/ConsoleInput.hx
+++ b/src/server/ConsoleInput.hx
@@ -107,7 +107,7 @@ class ConsoleInput {
case AddAdmin:
final name = args[0];
final password = args[1];
- if (main.badNickName(name)) {
+ if (main.isBadClientName(name)) {
final error = Lang.get("usernameError")
.replace("$MAX", '${main.config.maxLoginLength}');
trace(error);
diff --git a/src/server/HttpServer.hx b/src/server/HttpServer.hx
index 4734815..dd8c178 100644
--- a/src/server/HttpServer.hx
+++ b/src/server/HttpServer.hx
@@ -12,6 +12,7 @@ import js.node.http.ClientRequest;
import js.node.http.IncomingMessage;
import js.node.http.ServerResponse;
import js.node.url.URL;
+import server.cache.Cache;
import sys.FileSystem;
@:structInit
diff --git a/src/server/Main.hx b/src/server/Main.hx
index 86e619e..d935157 100644
--- a/src/server/Main.hx
+++ b/src/server/Main.hx
@@ -22,6 +22,7 @@ import js.npm.ws.Server as WSServer;
import js.npm.ws.WebSocket;
import json2object.ErrorUtils;
import json2object.JsonParser;
+import server.cache.Cache;
import sys.FileSystem;
import sys.io.File;
@@ -48,7 +49,7 @@ class Main {
var wss:WSServer;
final localIp:String;
var globalIp:String;
- final playersCacheSupport:Array<PlayerType> = [];
+ final playersCacheSupport:Array<PlayerType> = [RawType];
var port:Int;
final userList:UserList;
@@ -577,7 +578,7 @@ class Main {
case Login:
final name = data.login.clientName.trim();
final lcName = name.toLowerCase();
- if (badNickName(lcName)) {
+ if (isBadClientName(lcName)) {
serverMessage(client, "usernameError");
send(client, {type: LoginError});
return;
@@ -686,6 +687,11 @@ class Main {
addVideo();
} else {
switch item.playerType {
+ case RawType:
+ cache.cacheRawVideo(client, item.url, (name) -> {
+ item = item.withUrl(cache.getFileUrl(name));
+ addVideo();
+ });
case YoutubeType:
cache.cacheYoutubeVideo(client, item.url, (name) -> {
item = item.withUrl(cache.getFileUrl(name));
@@ -1065,7 +1071,7 @@ class Main {
final matchHtmlChars = ~/[&^<>'"]/;
final matchGuestName = ~/guest [0-9]+/;
- public function badNickName(name:String):Bool {
+ public function isBadClientName(name:String):Bool {
if (name.length > config.maxLoginLength) return true;
if (name.length == 0) return true;
if (matchHtmlChars.match(name)) return true;
@@ -1147,4 +1153,8 @@ class Main {
}
return false;
}
+
+ public function hasPlaylistUrl(url:String):Bool {
+ return videoList.exists(item -> item.url == url);
+ }
}
diff --git a/src/server/cache/Cache.hx b/src/server/cache/Cache.hx
new file mode 100644
index 0000000..f71b465
--- /dev/null
+++ b/src/server/cache/Cache.hx
@@ -0,0 +1,171 @@
+package server.cache;
+
+import haxe.io.Path;
+import js.node.Fs.Fs;
+import sys.FileSystem;
+
+class Cache {
+ public final notEnoughSpaceErrorText = "Error: Not enough free space on server or file size is out of cache storage limit.";
+
+ public final isYtReady = false;
+
+ /** In bytes **/
+ public var storageLimit(default, null) = 3 * 1024 * 1024 * 1024;
+
+ final main:Main;
+
+ public final cacheDir:String;
+ public final freeSpaceBlock = 10 * 1024 * 1024; // 10MB
+
+ final cachedFiles:Array<String> = [];
+ var youtubeCache:YoutubeCache;
+ var rawCache:RawCache;
+
+ public function new(main:Main, cacheDir:String) {
+ this.main = main;
+ this.cacheDir = cacheDir;
+ Utils.ensureDir(cacheDir);
+ youtubeCache = new YoutubeCache(main, this);
+ rawCache = new RawCache(main, this);
+ isYtReady = youtubeCache.checkYtDeps();
+ if (isYtReady) youtubeCache.cleanYtInputFiles();
+ }
+
+ public function getCachedFiles():Array<String> {
+ return cachedFiles;
+ }
+
+ public function setCachedFiles(names:Array<String>) {
+ cachedFiles.resize(0);
+ for (name in names) cachedFiles.push(name);
+
+ final names = FileSystem.readDirectory(cacheDir);
+ for (name in names) {
+ if (name.startsWith(".")) continue;
+ if (FileSystem.isDirectory('$cacheDir/$name')) continue;
+ if (cachedFiles.contains(name)) continue;
+ trace('Remove non-tracked cache $name');
+ remove(name);
+ }
+ }
+
+ public function log(client:Client, msg:String):Void {
+ main.serverMessage(client, msg);
+ trace(msg);
+ }
+
+ public function cacheYoutubeVideo(client:Client, url:String, callback:(name:String) -> Void) {
+ youtubeCache.cacheYoutubeVideo(client, url, callback);
+ }
+
+ public function cacheRawVideo(client:Client, url:String, callback:(name:String) -> Void) {
+ rawCache.cacheRawVideo(client, url, callback);
+ }
+
+ public function setStorageLimit(bytes:Int) {
+ storageLimit = cast bytes;
+ storageLimit = storageLimit.limitMin(0);
+ getFreeDiskSpace(availSpace -> {
+ final availSpace = (availSpace - freeSpaceBlock).limitMin(0);
+ removeOlderCache();
+ final freeSpace = getFreeSpace();
+ if (availSpace < freeSpace) {
+ // shrink limit lower than disk space
+ storageLimit += availSpace - freeSpace;
+ storageLimit = storageLimit.limitMin(0);
+ removeOlderCache();
+ }
+ });
+ }
+
+ public function getFreeDiskSpace(callback:(availSpace:Int) -> Void):Void {
+ final statfs = (Fs : Dynamic).statfs ?? {
+ trace("Warning: no fs.statfs support in current nodejs version (needs v18+)");
+ callback(storageLimit);
+ return;
+ }
+ statfs("/", (err, stats) -> {
+ if (err != null) {
+ trace(err);
+ callback(storageLimit);
+ return;
+ }
+ callback(stats.bsize * stats.bavail);
+ });
+ }
+
+ public function add(name:String) {
+ if (!cachedFiles.contains(name)) {
+ cachedFiles.unshift(name);
+ }
+ }
+
+ public function remove(name:String):Void {
+ cachedFiles.remove(name);
+ removeFile(name);
+ }
+
+ public function exists(name:String):Bool {
+ return cachedFiles.contains(name) && isFileExists(name);
+ }
+
+ /** Returns `true` if there is enough space to save `addFileSize` bytes. **/
+ public function removeOlderCache(addFileSize = 0):Bool {
+ var space = getUsedSpace(addFileSize);
+ for (name in cachedFiles.reversed()) {
+ if (space <= storageLimit) break;
+ // do not remove cached items that are in playlist
+ if (main.hasPlaylistUrl(getFileUrl(name))) continue;
+ remove(name);
+ space = getUsedSpace(addFileSize);
+ }
+ return space < storageLimit;
+ }
+
+ function removeFile(name:String):Void {
+ final path = getFilePath(name);
+ if (FileSystem.exists(path)) FileSystem.deleteFile(path);
+ }
+
+ public function getFreeFileName(fullName = "video.mp4"):String {
+ final baseName = Path.withoutDirectory(Path.withoutExtension(fullName));
+ final ext = Path.extension(fullName);
+ var i = 1;
+ while (true) {
+ final n = i == 1 ? "" : '$i';
+ final name = '$baseName$n.$ext';
+ if (!isFileExists(name)) return name;
+ i++;
+ }
+ }
+
+ public function getFilePath(name:String):String {
+ return '$cacheDir/$name';
+ }
+
+ public function getFileUrl(name:String):String {
+ final folder = Path.withoutDirectory(cacheDir);
+ return '/$folder/$name';
+ }
+
+ public function isFileExists(name:String):Bool {
+ return FileSystem.exists(getFilePath(name));
+ }
+
+ public function getFreeSpace():Int {
+ return storageLimit - getUsedSpace();
+ }
+
+ public function getUsedSpace(addFileSize = 0):Int {
+ var total = addFileSize.limitMin(0);
+ for (name in cachedFiles.reversed()) {
+ final path = getFilePath(name);
+ if (!FileSystem.exists(path)) {
+ cachedFiles.remove(name);
+ continue;
+ }
+ total += FileSystem.stat(path).size;
+ }
+ return total;
+ }
+}
diff --git a/src/server/cache/RawCache.hx b/src/server/cache/RawCache.hx
new file mode 100644
index 0000000..ed8679c
--- /dev/null
+++ b/src/server/cache/RawCache.hx
@@ -0,0 +1,469 @@
+package server.cache;
+
+import js.node.Buffer;
+import js.node.ChildProcess;
+import js.node.Fs.Fs;
+import js.node.Http;
+import js.node.Https;
+import js.node.http.ClientRequest;
+import js.node.http.IncomingMessage;
+import sys.FileSystem;
+import sys.io.File;
+
+typedef Segment = {
+ i:Int,
+ url:String,
+ started:Bool,
+ completed:Bool,
+ name:String
+}
+
+class RawCache {
+ final main:Main;
+ final cache:Cache;
+
+ public function new(main:Main, cache:Cache):Void {
+ this.main = main;
+ this.cache = cache;
+ }
+
+ public function cacheRawVideo(client:Client, url:String, callback:(name:String) -> Void) {
+ final isM3U8 = url.contains(".m3u8");
+ final ext = isM3U8 ? "m3u8" : "mp4";
+
+ final matchName = ~/^([^:.]+)\.(.+)/;
+ final decodedUrl = try url.urlDecode() catch (e) url;
+ final lastPart = decodedUrl.substr(decodedUrl.lastIndexOf("/") + 1);
+ var outName = matchName.match(lastPart) ? matchName.matched(1)
+ + '.$ext' : 'video.$ext';
+ outName = cache.getFreeFileName(outName);
+
+ if (cache.exists(outName)) {
+ callback(outName);
+ return;
+ }
+
+ trace('Caching $url to $outName...');
+ main.send(client, {
+ type: Progress,
+ progress: {
+ type: Caching,
+ ratio: 0,
+ data: outName
+ }
+ });
+
+ if (isM3U8) {
+ handleM3u8(client, url, outName, callback);
+ } else {
+ handleMp4(client, url, outName, callback);
+ }
+ }
+
+ function handleMp4(client:Client, url:String, outName:String, callback:(name:String) -> Void) {
+ downloadFile(client, url, outName, (downloaded, total) -> {
+ main.send(client, {
+ type: Progress,
+ progress: {
+ type: Downloading,
+ ratio: (downloaded / total).clamp(0, 1)
+ }
+ });
+ }, () -> {
+ cache.add(outName);
+ callback(outName);
+ }, (err) -> {
+ log(client, 'Mp4 download failed: $err');
+ cancelProgress(client);
+ });
+ }
+
+ function handleM3u8(client:Client, url:String, outName:String, callback:(name:String) -> Void):Void {
+ final useProxy = true;
+ downloadM3u8Playlist(client, url, useProxy, (playlist, totalSize, segments) -> {
+ // only playlist file donwloaded
+ if (useProxy) totalSize = playlist.length;
+
+ if (!cache.removeOlderCache(totalSize + cache.freeSpaceBlock)) {
+ log(client, cache.notEnoughSpaceErrorText);
+ cancelProgress(client);
+ return;
+ }
+
+ if (useProxy) {
+ main.send(client, {
+ type: Progress,
+ progress: {
+ type: Caching,
+ ratio: 1,
+ data: outName
+ }
+ });
+ File.saveContent('${cache.cacheDir}/$outName', playlist);
+ cache.add(outName);
+ callback(outName);
+ return;
+ }
+
+ var activeDownloads = 0;
+ final maxParallelDownloads = 10;
+ var downloaded = 0;
+
+ function downloadNextBatch():Void {
+ for (segment in segments) {
+ if (activeDownloads >= maxParallelDownloads) break;
+ if (segment.started) continue;
+ segment.started = true;
+ activeDownloads++;
+ trace("download segment", segment.i);
+
+ downloadFile(client, segment.url, segment.name,
+ (downloadedBytes, totalBytes) -> {},
+
+ () -> {
+ activeDownloads--;
+ segment.completed = true;
+ downloaded++;
+
+ final progress = downloaded / segments.length;
+ main.send(client, {
+ type: Progress,
+ progress: {
+ type: Downloading,
+ ratio: progress.clamp(0, 1)
+ }
+ });
+
+ if (downloaded == segments.length) {
+ trace('All ${downloaded}/${segments.length} segments downloaded');
+
+ File.saveContent('${cache.cacheDir}/$outName', playlist);
+ cache.add(outName);
+ callback(outName);
+ // buildTsFiles(
+ // segments.map(item -> item.name),
+ // outName,
+ // client,
+ // callback
+ // );
+ } else {
+ downloadNextBatch();
+ }
+ },
+
+ (err) -> {
+ activeDownloads--;
+ downloaded++;
+ log(client, 'TS segment ${segment.i} download failed: $err');
+ cancelProgress(client);
+ cleanupFiles(segments.map(item -> item.name));
+ }
+ );
+ }
+ }
+
+ // Start the initial batch of downloads
+ downloadNextBatch();
+ }, (err) -> {
+ log(client, 'M3U8 processing failed: $err');
+ cancelProgress(client);
+ });
+ }
+
+ function request(url:String, ?options:Null<HttpsRequestOptions>, ?callback:Null<IncomingMessage->
+ Void>):ClientRequest {
+ final httpsOptions:HttpsRequestOptions = options ?? {};
+ // Allow self-signed certificates
+ httpsOptions.rejectUnauthorized = false;
+ httpsOptions.headers ??= {};
+
+ if (url.startsWith("https:")) {
+ return Https.request(url, httpsOptions, callback);
+ } else {
+ return Http.request(url, httpsOptions, callback);
+ }
+ }
+
+ function downloadM3u8Playlist(
+ client:Client,
+ url:String,
+ useProxy:Bool,
+ onSuccess:(playlist:String, totalSize:Int, segments:Array<Segment>) -> Void,
+ onError:(err:String) -> Void
+ ) {
+ final options:HttpsRequestOptions = {
+ headers: {
+ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36",
+ "Accept": "*/*",
+ }
+ };
+
+ final req = request(url, options, (res:IncomingMessage) -> {
+ if (res.statusCode >= 300 && res.statusCode < 400) {
+ final redirectUrl = res.headers.get("location");
+ if (redirectUrl != null) {
+ downloadM3u8Playlist(client, redirectUrl, useProxy, onSuccess, onError);
+ return;
+ }
+ }
+
+ final body:Array<Any> = [];
+ res.on("data", chunk -> body.push(chunk));
+ res.on("end", () -> {
+ try {
+ final buffer = Buffer.concat(body);
+ final content = buffer.toString();
+ if (!~/^#EXTM3U/.match(content)) {
+ onError("Invalid M3U8 playlist");
+ return;
+ }
+
+ final baseUrl = url.substring(0, url.lastIndexOf("/") + 1);
+ final segments:Array<Segment> = [];
+
+ final lines = content.split("\n");
+ for (lineI => line in lines) {
+ final line = line.trim();
+ if (line.length == 0) continue;
+ if (line.startsWith("#")) continue;
+ final segmentUrl = !line.contains("://") ? baseUrl + line : line;
+ final i = segments.length;
+ final segment:Segment = {
+ i: i,
+ url: segmentUrl,
+ started: false,
+ completed: false,
+ name: 'segment$i.ts',
+ }
+ segments.push(segment);
+ lines[lineI] = './${segment.name}';
+ if (useProxy) {
+ lines[lineI] = '/proxy?url=$segmentUrl';
+ }
+ }
+
+ // Head request can return full stream size, so lets do loose assumption
+ final req = request(segments[0].url, {method: Get}, (res:IncomingMessage) -> {
+ final contentLength = Std.parseInt(res.headers["content-length"]) ?? 0;
+ final totalSize = contentLength * (segments.length + 1);
+ if (totalSize == 0) {
+ onError("Failed to get segment sizes: no content-length");
+ return;
+ }
+ onSuccess(lines.join("\n"), totalSize, segments);
+ });
+ req.on("error", (err) -> {
+ onError("Request error: failed to get segment sizes");
+ });
+ req.end();
+ } catch (e) {
+ onError('Playlist processing error: $e');
+ }
+ });
+ });
+
+ req.on("error", onError);
+ req.end();
+ }
+
+ function downloadFile(
+ client:Client, url:String, fileName:String,
+ onProgress:(downloaded:Int, total:Int) -> Void,
+ onComplete:() -> Void,
+ onError:(err:String) -> Void
+ ):Void {
+ final outPath = '${cache.cacheDir}/$fileName';
+ final file = Fs.createWriteStream(outPath);
+ final options:HttpsRequestOptions = {
+ method: Get,
+ headers: {
+ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537." +
+ Std.random(100),
+ "Accept": "*/*",
+ }
+ };
+ final req = request(url, options, (res:IncomingMessage) -> {
+ final total = Std.parseInt(res.headers["content-length"]) ?? 0;
+ var downloaded = 0;
+
+ // Handle response data chunks
+ res.on("data", (chunk) -> {
+ downloaded += chunk.length;
+ onProgress(downloaded, total);
+
+ // Handle backpressure
+ if (!file.write(chunk)) {
+ res.pause();
+ file.once("drain", () -> res.resume());
+ }
+ });
+
+ // Handle response completion
+ res.on("end", () -> file.end());
+
+ // Handle response errors
+ res.on("error", (err) -> {
+ file.destroy();
+ onError('Response error: $err');
+ });
+ });
+
+ // Handle file write completion
+ file.on("finish", onComplete);
+
+ // Handle file system errors
+ file.on("error", (err) -> {
+ req.destroy();
+ onError('File error: $err');
+ });
+
+ // Handle request errors
+ req.on("error", (err) -> {
+ file.destroy();
+ onError('Request failed: $err');
+ });
+
+ req.end();
+ }
+
+ function buildTsFiles(tempFiles:Array<String>, outName:String, client:Client, callback:String->Void) {
+ final missingFiles = tempFiles.filter(f ->
+ !FileSystem.exists('${cache.cacheDir}/$f'));
+ if (missingFiles.length > 0) {
+ log(client, 'Concatenation failed: ${missingFiles.length} segments are missing');
+ main.send(client, {
+ type: Progress,
+ progress: {
+ type: Canceled,
+ ratio: 1
+ }
+ });
+ cleanupFiles(tempFiles);
+ return;
+ }
+
+ // Create concat file with absolute paths and proper escaping
+ final concatFile = 'concat_list.txt';
+ final concatContent = tempFiles.map(f -> {
+ final path = './$f';
+ return 'file \'${path}\'';
+ }).join("\n");
+
+ File.saveContent('${cache.cacheDir}/$concatFile', concatContent);
+
+ // Prepare FFmpeg args with proper bitstream filters for TS files
+ final args = [
+ "-y", // Overwrite output without asking
+ "-f", "concat", // Use concat format
+ "-safe", "0", // Allow absolute paths
+ "-i", concatFile, // Input file list
+ "-c", "copy", // Copy streams without re-encoding
+ "-bsf:a", "aac_adtstoasc", // Fix AAC audio streams from TS files
+ "-movflags", "+faststart", // Optimize for web streaming
+ outName // Output filename
+ ];
+
+ trace('Executing FFmpeg with args: ${args.join(" ")}');
+
+ // Create process with proper error capturing
+ final process = ChildProcess.spawn("ffmpeg", args, {
+ cwd: cache.cacheDir,
+ // stderr: "pipe" // Capture stderr for error reporting
+ });
+
+ final errorOutput:Array<Buffer> = [];
+ process.stderr.on("data", (data) -> errorOutput.push(data));
+
+ // Set a reasonable timeout
+ final timeout = 5 * 60 * 1000; // 5 minutes
+ final timeoutId = js.Node.setTimeout(() -> {
+ process.kill();
+ log(client, 'FFmpeg process timed out after ${timeout / 1000} seconds');
+ main.send(client, {
+ type: Progress,
+ progress: {
+ type: Canceled,
+ ratio: 1
+ }
+ });
+ cleanupFiles(tempFiles.concat([concatFile]));
+ }, timeout);
+
+ process.on("close", (code:Int) -> {
+ js.Node.clearTimeout(timeoutId);
+
+ if (code != 0) {
+ final errorMsg = Buffer.concat(errorOutput).toString();
+ log(client, 'FFmpeg concatenation failed with code $code');
+ trace('FFmpeg error output: $errorMsg');
+
+ // Log detailed error to admins
+ final admins = main.clients.filter(client -> client.isAdmin);
+ for (admin in admins) {
+ log(admin, 'FFmpeg error: $errorMsg');
+ }
+
+ main.send(client, {
+ type: Progress,
+ progress: {
+ type: Canceled,
+ ratio: 1
+ }
+ });
+ } else {
+ // Verify the output file exists and has content
+ if (FileSystem.exists('${cache.cacheDir}/$outName')
+ && FileSystem.stat('${cache.cacheDir}/$outName').size > 0) {
+ cache.add(outName);
+ callback(outName);
+ } else {
+ log(client, 'FFmpeg process completed but output file is missing or empty');
+ main.send(client, {
+ type: Progress,
+ progress: {
+ type: Canceled,
+ ratio: 1
+ }
+ });
+ }
+ }
+
+ // Clean up temporary files after everything is done
+ cleanupFiles(tempFiles.concat([concatFile]));
+ });
+
+ // Handle process errors (like if FFmpeg isn't found)
+ process.on("error", (err) -> {
+ js.Node.clearTimeout(timeoutId);
+ log(client, 'Failed to start FFmpeg: $err');
+ main.send(client, {
+ type: Progress,
+ progress: {
+ type: Canceled,
+ ratio: 1
+ }
+ });
+ cleanupFiles(tempFiles.concat([concatFile]));
+ });
+ }
+
+ function cleanupFiles(files:Array<String>):Void {
+ for (file in files) {
+ if (FileSystem.exists(file)) FileSystem.deleteFile(file);
+ }
+ }
+
+ function log(client:Client, msg:String):Void {
+ cache.log(client, msg);
+ }
+
+ function cancelProgress(client:Client):Void {
+ main.send(client, {
+ type: Progress,
+ progress: {
+ type: Canceled,
+ ratio: 0
+ }
+ });
+ }
+}
diff --git a/src/server/Cache.hx b/src/server/cache/YoutubeCache.hx
index ef74517..c0a5c4c 100644
--- a/src/server/Cache.hx
+++ b/src/server/cache/YoutubeCache.hx
@@ -1,7 +1,6 @@
-package server;
+package server.cache;
import haxe.Json;
-import haxe.io.Path;
import js.lib.Promise;
import js.node.Buffer;
import js.node.ChildProcess;
@@ -11,28 +10,16 @@ import sys.FileSystem;
import sys.io.File;
import utils.YoutubeUtils;
-class Cache {
- public final notEnoughSpaceErrorText = "Error: Not enough free space on server or file size is out of cache storage limit.";
-
- public final isYtReady = false;
-
- /** In bytes **/
- public var storageLimit(default, null) = 3 * 1024 * 1024 * 1024;
-
+class YoutubeCache {
final main:Main;
- final cacheDir:String;
- final cachedFiles:Array<String> = [];
- final freeSpaceBlock = 10 * 1024 * 1024; // 10MB
+ final cache:Cache;
- public function new(main:Main, cacheDir:String) {
+ public function new(main:Main, cache:Cache):Void {
this.main = main;
- this.cacheDir = cacheDir;
- Utils.ensureDir(cacheDir);
- isYtReady = checkYtDeps();
- if (isYtReady) cleanYtInputFiles();
+ this.cache = cache;
}
- function checkYtDeps():Bool {
+ public function checkYtDeps():Bool {
final ytdl = try {
untyped require("@distube/ytdl-core");
} catch (e) {
@@ -46,39 +33,16 @@ class Cache {
}
}
- function cleanYtInputFiles():Void {
- final names = FileSystem.readDirectory(cacheDir);
+ public function cleanYtInputFiles():Void {
+ final names = FileSystem.readDirectory(cache.cacheDir);
for (name in names) {
if (!name.startsWith("__tmp")) continue;
- remove(name);
- }
- }
-
- public function getCachedFiles():Array<String> {
- return cachedFiles;
- }
-
- public function setCachedFiles(names:Array<String>) {
- cachedFiles.resize(0);
- for (name in names) cachedFiles.push(name);
-
- final names = FileSystem.readDirectory(cacheDir);
- for (name in names) {
- if (name.startsWith(".")) continue;
- if (FileSystem.isDirectory('$cacheDir/$name')) continue;
- if (cachedFiles.contains(name)) continue;
- trace('Remove non-tracked cache $name');
- remove(name);
+ cache.remove(name);
}
}
- function log(client:Client, msg:String):Void {
- main.serverMessage(client, msg);
- trace(msg);
- }
-
public function cacheYoutubeVideo(client:Client, url:String, callback:(name:String) -> Void) {
- if (!isYtReady) {
+ if (!cache.isYtReady) {
trace("Do `npm i @distube/ytdl-core@latest` to use cache feature (you also need to install `ffmpeg` to build mp4 from downloaded audio/video tracks).");
return;
}
@@ -88,36 +52,27 @@ class Cache {
return;
}
final outName = videoId + ".mp4";
- if (cachedFiles.contains(outName) && isFileExists(outName)) {
+ if (cache.exists(outName)) {
callback(outName);
return;
}
final inVideoName = '__tmp-video-$videoId';
final inAudioName = '__tmp-audio-$videoId';
inline function removeInputFiles():Void {
- remove(inVideoName);
- remove(inAudioName);
- }
- inline function cancelProgress():Void {
- main.send(client, {
- type: Progress,
- progress: {
- type: Canceled,
- ratio: 1
- }
- });
+ cache.remove(inVideoName);
+ cache.remove(inAudioName);
}
inline function checkEnoughSpace(contentLength:Int):Bool {
- final hasSpace = removeOlderCache(contentLength + freeSpaceBlock);
+ final hasSpace = cache.removeOlderCache(contentLength + cache.freeSpaceBlock);
if (!hasSpace) {
removeInputFiles();
- cancelProgress();
- log(client, notEnoughSpaceErrorText);
+ cancelProgress(client);
+ log(client, cache.notEnoughSpaceErrorText);
}
return hasSpace;
}
- if (isFileExists(inVideoName)) {
+ if (cache.isFileExists(inVideoName)) {
log(client, 'Caching $outName already in progress');
return;
}
@@ -162,7 +117,8 @@ class Cache {
return videoSize + audioSize;
}
// check if we have space for formats and video build
- final hasSpace = removeOlderCache(getTotalFormatsSize() * 2 + freeSpaceBlock);
+ final hasSpace = cache.removeOlderCache(getTotalFormatsSize() * 2
+ + cache.freeSpaceBlock);
if (!hasSpace) {
// try fallback to worse video quality
videoFormat = getBestYoutubeVideoFormat(info.formats, videoFormat.qualityLabel);
@@ -173,22 +129,22 @@ class Cache {
format: videoFormat,
agent: agent,
});
- dlVideo.pipe(Fs.createWriteStream('$cacheDir/$inVideoName'));
+ dlVideo.pipe(Fs.createWriteStream('${cache.cacheDir}/$inVideoName'));
dlVideo.on("error", err -> {
log(client, "Error during video download: " + err);
removeInputFiles();
- cancelProgress();
+ cancelProgress(client);
});
final dlAudio:Readable<Dynamic> = ytdl(url, {
format: audioFormat,
agent: agent,
});
- dlAudio.pipe(Fs.createWriteStream('$cacheDir/$inAudioName'));
+ dlAudio.pipe(Fs.createWriteStream('${cache.cacheDir}/$inAudioName'));
dlAudio.on("error", err -> {
log(client, "Error during audio download: " + err);
removeInputFiles();
- cancelProgress();
+ cancelProgress(client);
});
var count = 0;
@@ -196,20 +152,20 @@ class Cache {
count++;
trace('$type track downloaded ($count/2)');
if (count < 2) return;
- if (!isFileExists(inVideoName) || !isFileExists(inAudioName)) {
+ if (!cache.isFileExists(inVideoName) || !cache.isFileExists(inAudioName)) {
log(client, "Input files not found for making final video");
removeInputFiles();
- cancelProgress();
+ cancelProgress(client);
return;
}
- var size = FileSystem.stat('$cacheDir/$inVideoName').size;
- size += FileSystem.stat('$cacheDir/$inAudioName').size;
+ var size = FileSystem.stat('${cache.cacheDir}/$inVideoName').size;
+ size += FileSystem.stat('${cache.cacheDir}/$inAudioName').size;
// clean some space for full mp4
if (!checkEnoughSpace(size)) return;
final args = '-y -i ./$inVideoName -i ./$inAudioName -c copy -map 0:v -map 1:a ./$outName'.split(" ");
final process = ChildProcess.spawn("ffmpeg", args, {
- cwd: cacheDir,
+ cwd: cache.cacheDir,
// stdio: "ignore"
});
final outputData:Array<Buffer> = [];
@@ -217,7 +173,7 @@ class Cache {
process.on("close", (code:Int) -> {
removeInputFiles();
if (code != 0) {
- cancelProgress();
+ cancelProgress(client);
final errCodeMsg = 'Error: ffmpeg closed with code $code';
final admins = main.clients.filter(client -> client.isAdmin);
for (client in admins) {
@@ -227,7 +183,7 @@ class Cache {
if (!admins.contains(client)) log(client, errCodeMsg);
return;
}
- add(outName);
+ cache.add(outName);
callback(outName);
});
@@ -246,112 +202,11 @@ class Cache {
});
}).catchError(err -> {
removeInputFiles();
- cancelProgress();
+ cancelProgress(client);
log(client, "" + err);
});
}
- public function setStorageLimit(bytes:Int) {
- storageLimit = cast bytes;
- storageLimit = storageLimit.limitMin(0);
- getFreeDiskSpace(availSpace -> {
- final availSpace = (availSpace - freeSpaceBlock).limitMin(0);
- removeOlderCache();
- final freeSpace = getFreeSpace();
- if (availSpace < freeSpace) {
- // shrink limit lower than disk space
- storageLimit += availSpace - freeSpace;
- storageLimit = storageLimit.limitMin(0);
- removeOlderCache();
- }
- });
- }
-
- public function getFreeDiskSpace(callback:(availSpace:Int) -> Void):Void {
- final statfs = (Fs : Dynamic).statfs ?? {
- trace("Warning: no fs.statfs support in current nodejs version (needs v18+)");
- callback(storageLimit);
- return;
- }
- statfs("/", (err, stats) -> {
- if (err != null) {
- trace(err);
- callback(storageLimit);
- return;
- }
- callback(stats.bsize * stats.bavail);
- });
- }
-
- public function add(name:String) {
- if (!cachedFiles.contains(name)) {
- cachedFiles.unshift(name);
- }
- }
-
- public function remove(name:String):Void {
- cachedFiles.remove(name);
- removeFile(name);
- }
-
- /** Returns `true` if there is enough space to save `addFileSize` bytes. **/
- public function removeOlderCache(addFileSize = 0):Bool {
- var space = getUsedSpace(addFileSize);
- while (space > storageLimit) {
- final name = cachedFiles.pop() ?? break;
- removeFile(name);
- space = getUsedSpace(addFileSize);
- }
- return space < storageLimit;
- }
-
- function removeFile(name:String):Void {
- final path = getFilePath(name);
- if (FileSystem.exists(path)) FileSystem.deleteFile(path);
- }
-
- public function getFreeFileName(fullName = "video.mp4"):String {
- final baseName = Path.withoutDirectory(Path.withoutExtension(fullName));
- final ext = Path.extension(fullName);
- var i = 1;
- while (true) {
- final n = i == 1 ? "" : '$i';
- final name = '$baseName$n.$ext';
- if (!isFileExists(name)) return name;
- i++;
- }
- }
-
- public function getFilePath(name:String):String {
- return '$cacheDir/$name';
- }
-
- public function getFileUrl(name:String):String {
- final folder = Path.withoutDirectory(cacheDir);
- return '/$folder/$name';
- }
-
- public function isFileExists(name:String):Bool {
- return FileSystem.exists(getFilePath(name));
- }
-
- public function getFreeSpace():Int {
- return storageLimit - getUsedSpace();
- }
-
- public function getUsedSpace(addFileSize = 0):Int {
- var total = addFileSize.limitMin(0);
- for (name in cachedFiles.reversed()) {
- final path = getFilePath(name);
- if (!FileSystem.exists(path)) {
- cachedFiles.remove(name);
- continue;
- }
- total += FileSystem.stat(path).size;
- }
- return total;
- }
-
function getBestYoutubeVideoFormat(formats:Array<YoutubeVideoFormat>, ?ignoreQuality:String):Null<YoutubeVideoFormat> {
final qPriority = [1080, 720, 480, 360, 240, 144];
for (q in qPriority) {
@@ -364,4 +219,18 @@ class Cache {
}
return null;
}
+
+ function log(client:Client, msg:String):Void {
+ cache.log(client, msg);
+ }
+
+ function cancelProgress(client:Client):Void {
+ main.send(client, {
+ type: Progress,
+ progress: {
+ type: Canceled,
+ ratio: 0
+ }
+ });
+ }
}
send patches to the email below
yukais@pinapelz.com
include the subject [PATCH repo_name]
pinapelz.com
homepage