diff options
| -rw-r--r-- | .github/workflows/main.yml | 2 | ||||
| -rw-r--r-- | build/server.js | 1204 | ||||
| -rw-r--r-- | res/client.js | 77 | ||||
| -rw-r--r-- | src/VideoList.hx | 11 | ||||
| -rw-r--r-- | src/client/Buttons.hx | 21 | ||||
| -rw-r--r-- | src/client/ClientSettings.hx | 31 | ||||
| -rw-r--r-- | src/client/Main.hx | 23 | ||||
| -rw-r--r-- | src/server/ConsoleInput.hx | 2 | ||||
| -rw-r--r-- | src/server/HttpServer.hx | 1 | ||||
| -rw-r--r-- | src/server/Main.hx | 16 | ||||
| -rw-r--r-- | src/server/cache/Cache.hx | 171 | ||||
| -rw-r--r-- | src/server/cache/RawCache.hx | 469 | ||||
| -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 + } + }); + } } |
