diff --git a/README.md b/README.md index 09a3a47..da68f4a 100644 --- a/README.md +++ b/README.md @@ -1,34 +1,40 @@ -## SyncTube +# SyncTube Synchronized video viewing with chat and other features. Lightweight modern implementation and very easy way to run locally. Default channel example: https://synctube.onrender.com/ -### Features -- Multi-Language support -- Hotkeys (`Alt-P` for global play/pause, [etc](https://github.com/RblSb/SyncTube/blob/d3f6d4e6434527569d13f211a0eb074c5a11992e/src/client/Buttons.hx#L303-L314)) -- Mobile view with page fullscreen -- Way to play local videos for network users (without NAT loopback feature) +## Features +- Control video playback for all users with active `Leader` button +- Start watching local videos while uploading them on server, before upload completes +- External `vtt`/`srt`/`ass` subtitles support +- External audiotrack / voiceover support +- `/30`, `/-21`, etc chat commands to rewind video playback in seconds +- Hotkeys (`Alt-P` for global play/pause, [etc](https://github.com/RblSb/SyncTube/blob/382f9b2ebedca905028341825350a0fa69d88673/src/client/Buttons.hx#L416-L427)) +- Compact view button with page fullscreen on Android - Playback rate synchronization (with leader) -- `/30`, `/-21`, etc to rewind video playback in seconds - Links mask: `foo.com/bar${1-4}.mp4` to add multiple items - Override every front-end file you want (`user/res` folder) - [Native mobile client](https://github.com/RblSb/SyncTubeApp) -### Supported players +### Easier playback controls for smaller groups +- Enable `requestLeaderOnPause` to allow global pause by anybody, without `Leader` button +- Enable `unpauseWithoutLeader` to allow global unpause for non-leaders + +## Supported players - Youtube (videos, shorts, streams and playlists) - [Streamable](https://streamable.com) - [VK](https://vk.com/video) - Raw mp4 videos and m3u8 playlists (or any other media format supported in browser) - Iframes (without sync) -### Setup +## Setup - Open `4200` port in your router settings (port is customizable) - `npm ci` in this project folder ([NodeJS 14+](https://nodejs.org) required) - Run `node build/server.js` - Open showed "Local" link for yourself and send "Global" link to friends -### Setup (Docker) +## Setup (Docker) As alternative, you can install Docker and run: > ```shell > docker build -t synctube . @@ -44,40 +50,54 @@ or - (Docker container hides real local/global ips, so you need to checkout it manually) -### Optional dependencies +## Optional dependencies If you want to enable `Cache on server` feature for Youtube player, you can also run: ```shell npm i @distube/ytdl-core@latest ``` -And install `ffmpeg` on your server system. Default cache size is 3.0 GiB. +And install `ffmpeg` on your server system, it's only used to build single mp4 from downloaded audio/video tracks. Default cache size is 3.0 GiB. -### Configuration +## Configuration It's just works, but you can also check [user/ folder](/user/README.md) for server settings and additional customization. -### Plugins -- [octosubs](https://github.com/RblSb/SyncTube-octosubs) - `ASS`/`SSA` subtitles support -- [qswitcher](https://github.com/aNNiMON/SyncTube-QSwitcher) - raw video quality switcher - -### How to use +## How to use - Login with any nickname - Add your video url with "plus" button below (youtube or direct link to mp4 for example) - Now it plays and syncs for all page users, well done -- You can click "leader" button to get access to global video controls (play/pause, time setting, playback speed) +- You can click "leader" button to get access to global video controls (play/pause, seeking, playback speed) - If you want to restrict permissions or add admins/emotes, see `Configuration` above -### Intergations -#### Heroku: +## Chat commands +- `/-1h9m54` - Command format to rewind video **back** by `1 hour 9 minutes 54 seconds` +- `/ad` - Rewind sponsored block in active YouTube video +- `/fb` (`/flashback`) - rewind video to a prev time if someone rewinded/restarted video accidentally +- `/clear` - Clear chat. Admin clears chat globally +- `/help` - Show initial tutorial message + +### Admins only: + +- `/ban Guest 1 2h` - Ban user `Guest 1` ip for `2 hours` +- `/unban Foo` (`/removeBan`) - Unban user `Foo` +- `/kick Foo` - Force `Foo` disconnection until page reload +- `/dump` - Download state dump to report issues + +## Plugins +- [octosubs](https://github.com/RblSb/SyncTube-octosubs) - More colorful `ASS`/`SSA` subtitles support +- [qswitcher](https://github.com/aNNiMON/SyncTube-QSwitcher) - Raw video quality switcher + +## Intergations +### Heroku: - Create app and commit repo to get build - Remove `user/` folder from `.gitignore` and commit it to change default configuration - Add `APP_URL` config var with `your-app-link.herokuapp.com` value to prevent sleeping when clients online -### Development +## Development - Install [Haxe 4.3](https://haxe.org/download/), [VSCode](https://code.visualstudio.com) and [Haxe extension](https://marketplace.visualstudio.com/items?itemName=nadako.vshaxe) - `haxelib install all` to install extern libs - If you skipped `Setup` section before: `npm ci` - Open project in VSCode and press `F5` for client+server build and run -### About -- Layout and design by [Austin Riddell](https://github.com/aus-tin) +## About +- [Redesign](https://github.com/RblSb/SyncTube/pull/5) by Austin Riddell - [Original idea](https://github.com/calzoneman/sync) by Calvin Montgomery - Default emotes by [emlan](https://www.deviantart.com/emlan) diff --git a/build/server.js b/build/server.js index 28d861d..d2062b0 100644 --- a/build/server.js +++ b/build/server.js @@ -2447,6 +2447,9 @@ VideoList.prototype = { } this.pos = i; } + ,hasItem: function(i) { + return this.items[i] != null; + } ,exists: function(f) { return Lambda.exists(this.items,f); } @@ -3685,6 +3688,9 @@ var server_Cache = function(main,cacheDir) { this.cacheDir = cacheDir; server_Utils.ensureDir(cacheDir); this.isYtReady = this.checkYtDeps(); + if(this.isYtReady) { + this.cleanYtInputFiles(); + } }; server_Cache.__name__ = true; server_Cache.prototype = { @@ -3702,14 +3708,26 @@ server_Cache.prototype = { 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); + } + } ,log: function(client,msg) { this.main.serverMessage(client,msg); - haxe_Log.trace(msg,{ fileName : "src/server/Cache.hx", lineNumber : 46, className : "server.Cache", methodName : "log"}); + haxe_Log.trace(msg,{ fileName : "src/server/Cache.hx", lineNumber : 56, 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 : 51, className : "server.Cache", methodName : "cacheYoutubeVideo"}); + 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 : 61, className : "server.Cache", methodName : "cacheYoutubeVideo"}); return; } var videoId = utils_YoutubeUtils.extractVideoId(url); @@ -3722,12 +3740,18 @@ server_Cache.prototype = { 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 : 65, className : "server.Cache", methodName : "cacheYoutubeVideo"}); + haxe_Log.trace("Caching " + url + " to " + outName + "...",{ fileName : "src/server/Cache.hx", lineNumber : 85, className : "server.Cache", methodName : "cacheYoutubeVideo"}); this.main.send(client,{ type : "Progress", progress : { type : "Caching", ratio : 0, data : outName}}); var promise = ytdl.getInfo(url); promise.then(function(info) { - haxe_Log.trace("Get info with " + info.formats.length + " formats",{ fileName : "src/server/Cache.hx", lineNumber : 76, className : "server.Cache", methodName : "cacheYoutubeVideo"}); + haxe_Log.trace("Get info with " + info.formats.length + " formats",{ fileName : "src/server/Cache.hx", lineNumber : 96, className : "server.Cache", methodName : "cacheYoutubeVideo"}); var audioFormat; try { var ytdl1 = ytdl.chooseFormat; @@ -3746,7 +3770,7 @@ server_Cache.prototype = { } 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 : 83, className : "server.Cache", methodName : "cacheYoutubeVideo"}); + haxe_Log.trace(e,{ fileName : "src/server/Cache.hx", lineNumber : 103, className : "server.Cache", methodName : "cacheYoutubeVideo"}); var _g1 = []; var _g2 = 0; var _g3 = info.formats; @@ -3757,7 +3781,7 @@ server_Cache.prototype = { _g1.push(v); } } - haxe_Log.trace(_g1,{ fileName : "src/server/Cache.hx", lineNumber : 84, className : "server.Cache", methodName : "cacheYoutubeVideo"}); + haxe_Log.trace(_g1,{ fileName : "src/server/Cache.hx", lineNumber : 104, className : "server.Cache", methodName : "cacheYoutubeVideo"}); return; } var videoFormat; @@ -3776,40 +3800,47 @@ server_Cache.prototype = { _g.push(v); } } - haxe_Log.trace(_g,{ fileName : "src/server/Cache.hx", lineNumber : 89, className : "server.Cache", methodName : "cacheYoutubeVideo"}); + haxe_Log.trace(_g,{ fileName : "src/server/Cache.hx", lineNumber : 109, className : "server.Cache", methodName : "cacheYoutubeVideo"}); return; } var dlVideo = ytdl(url,{ format : videoFormat}); - dlVideo.pipe(js_node_Fs.createWriteStream("" + _gthis.cacheDir + "/input-video")); + 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); }); var dlAudio = ytdl(url,{ format : audioFormat}); - dlAudio.pipe(js_node_Fs.createWriteStream("" + _gthis.cacheDir + "/input-audio")); + 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); }); var count = 0; var onComplete = function(type) { count += 1; - haxe_Log.trace("" + type + " track downloaded (" + count + "/2)",{ fileName : "src/server/Cache.hx", lineNumber : 108, className : "server.Cache", methodName : "cacheYoutubeVideo"}); + haxe_Log.trace("" + type + " track downloaded (" + count + "/2)",{ fileName : "src/server/Cache.hx", lineNumber : 134, className : "server.Cache", methodName : "cacheYoutubeVideo"}); if(count < 2) { return; } - var size = js_node_Fs.statSync("" + _gthis.cacheDir + "/input-video").size; - size += js_node_Fs.statSync("" + _gthis.cacheDir + "/input-audio").size; + if(!_gthis.isFileExists(inVideoName) || !_gthis.isFileExists(inAudioName)) { + _gthis.remove(inVideoName); + _gthis.remove(inAudioName); + return; + } + var size = js_node_Fs.statSync("" + _gthis.cacheDir + "/" + inVideoName).size; + size += js_node_Fs.statSync("" + _gthis.cacheDir + "/" + inAudioName).size; _gthis.removeOlderCache(size + _gthis.freeSpaceBlock); - var args = ("-y -i input-video -i input-audio -c copy -map 0:v -map 1:a ./" + outName).split(" "); + 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, stdio : "ignore"}); $process.on("close",function(code) { + _gthis.remove(inVideoName); + _gthis.remove(inAudioName); if(code != 0) { _gthis.log(client,"Error: ffmpeg closed with code " + code); return; } - var inVideo = "" + _gthis.cacheDir + "/input-video"; - var inAudio = "" + _gthis.cacheDir + "/input-audio"; - js_node_Fs.unlinkSync(inVideo); - js_node_Fs.unlinkSync(inAudio); _gthis.add(outName); callback(outName); }); @@ -3838,6 +3869,8 @@ server_Cache.prototype = { _gthis.main.send(client,{ type : "Progress", progress : { type : "Downloading", ratio : ratio}}); }); }).catch(function(err) { + _gthis.remove(inVideoName); + _gthis.remove(inAudioName); _gthis.log(client,"" + err); }); } @@ -3852,7 +3885,7 @@ server_Cache.prototype = { } tmp("/",function(err,stats) { if(err != null) { - haxe_Log.trace(err,{ fileName : "src/server/Cache.hx", lineNumber : 173, className : "server.Cache", methodName : "setStorageLimit"}); + haxe_Log.trace(err,{ fileName : "src/server/Cache.hx", lineNumber : 200, className : "server.Cache", methodName : "setStorageLimit"}); return; } var a = stats.bsize * stats.bavail - _gthis.freeSpaceBlock; @@ -3871,6 +3904,10 @@ server_Cache.prototype = { this.cachedFiles.unshift(name); } } + ,remove: function(name) { + HxOverrides.remove(this.cachedFiles,name); + this.removeFile(name); + } ,removeOlderCache: function(addFileSize) { if(addFileSize == null) { addFileSize = 0; @@ -3881,20 +3918,25 @@ server_Cache.prototype = { if(tmp == null) { break; } - var path = this.getFilePath(tmp); - if(sys_FileSystem.exists(path)) { - js_node_Fs.unlinkSync(path); - } + this.removeFile(tmp); space = this.getUsedSpace(addFileSize); } } - ,getFreeFileName: function(baseName) { - if(baseName == null) { - baseName = "video"; + ,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) + ".mp4"; + var name = "" + baseName + (i == 1 ? "" : "" + i) + "." + ext; if(!this.isFileExists(name)) { return name; } @@ -4131,7 +4173,7 @@ var server_HttpServer = function(main,config) { this.matchVarString = new EReg("\\${([A-z_]+)}","g"); this.matchLang = new EReg("^[A-z]+",""); this.uploadingFilesLastChunks = new haxe_ds_StringMap(); - this.uploadingFiles = new haxe_ds_StringMap(); + this.uploadingFilesSizes = new haxe_ds_StringMap(); this.CHUNK_SIZE = 5242880; this.cache = null; this.allowLocalRequests = false; @@ -4158,8 +4200,8 @@ server_HttpServer.prototype = { } var filePath = this.getPath(this.dir,url); var ext = haxe_io_Path.extension(filePath).toLowerCase(); - res.setHeader("Accept-Ranges","bytes"); - res.setHeader("Content-Type",this.getMimeType(ext)); + res.setHeader("accept-ranges","bytes"); + res.setHeader("content-type",this.getMimeType(ext)); if(this.cache != null && req.method == "POST") { switch(url.pathname) { case "/upload": @@ -4225,96 +4267,86 @@ server_HttpServer.prototype = { req.on("end",function() { var buffer = js_node_buffer_Buffer.concat(body); _gthis.uploadingFilesLastChunks.h[filePath] = buffer; - res.writeHead(200,{ "Content-Type" : "application/json"}); - res.end(JSON.stringify({ info : "File last chunk uploaded"})); + res.writeHead(200,{ "content-type" : _gthis.getMimeType("json")}); + var json = { info : "File last chunk uploaded", url : _gthis.cache.getFileUrl(name)}; + res.end(JSON.stringify(json)); }); } ,uploadFile: function(req,res) { var _gthis = this; var name = this.cache.getFreeFileName(req.headers["content-name"]); - var clientName = req.headers["client-name"]; var filePath = this.cache.getFilePath(name); - var size; var tmp = Std.parseInt(req.headers["content-length"]); - if(tmp != null) { - size = tmp; - } else { + if(tmp == null) { return; } - var written = 0; - if(this.cache.getFreeSpace() < size) { - var _this = _gthis.uploadingFiles; - if(Object.prototype.hasOwnProperty.call(_this.h,name)) { - delete(_this.h[name]); + if(tmp < this.cache.storageLimit) { + this.cache.removeOlderCache(tmp); + } + if(this.cache.getFreeSpace() < tmp) { + res.statusCode = 413; + res.end(JSON.stringify({ info : "Error: Not enough free space on server or file size is out of cache storage limit.", errorId : "freeSpace"})); + var _this = _gthis.uploadingFilesSizes; + if(Object.prototype.hasOwnProperty.call(_this.h,filePath)) { + delete(_this.h[filePath]); } var _this = _gthis.uploadingFilesLastChunks; - if(Object.prototype.hasOwnProperty.call(_this.h,name)) { - delete(_this.h[name]); + if(Object.prototype.hasOwnProperty.call(_this.h,filePath)) { + delete(_this.h[filePath]); } - res.statusCode = 200; - res.end(JSON.stringify({ info : "Error: Not enough free space on server or file size is out of cache storage limit.", errorId : "freeSpace"})); + this.cache.remove(name); + req.destroy(); + var tmp1 = ClientTools.getByName(this.main.clients,name); + if(tmp1 == null) { + return; + } + this.main.serverMessage(tmp1,"Error: Not enough free space on server or file size is out of cache storage limit."); return; } var stream = js_node_Fs.createWriteStream(filePath); req.pipe(stream); - var isStart = true; - req.on("data",function(chunk) { - var url = null; - if(isStart) { - isStart = false; - _gthis.cache.removeOlderCache(size); - _gthis.cache.add(name); - _gthis.uploadingFiles.h[filePath] = size; - url = _gthis.cache.getFileUrl(name); - } - written += chunk.length; - var v = written / size; - var ratio = v < 0 ? 0 : v > 1 ? 1 : v; - tools_MathTools.toFixed(ratio * 100,2); - var tmp = ClientTools.getByName(_gthis.main.clients,clientName); - if(tmp == null) { - return; - } - _gthis.main.send(tmp,{ type : "Progress", progress : { type : "Uploading", ratio : ratio, data : url}}); - }); + this.cache.add(name); + this.uploadingFilesSizes.h[filePath] = tmp; stream.on("close",function() { - var _this = _gthis.uploadingFiles; - if(Object.prototype.hasOwnProperty.call(_this.h,name)) { - delete(_this.h[name]); + res.statusCode = 200; + res.end(JSON.stringify({ info : "File write stream closed."})); + var _this = _gthis.uploadingFilesSizes; + if(Object.prototype.hasOwnProperty.call(_this.h,filePath)) { + delete(_this.h[filePath]); } var _this = _gthis.uploadingFilesLastChunks; - if(Object.prototype.hasOwnProperty.call(_this.h,name)) { - delete(_this.h[name]); + if(Object.prototype.hasOwnProperty.call(_this.h,filePath)) { + delete(_this.h[filePath]); } - res.statusCode = 200; - res.end(JSON.stringify({ info : "File write stream closed."})); }); stream.on("error",function(err) { - haxe_Log.trace(err,{ fileName : "src/server/HttpServer.hx", lineNumber : 210, className : "server.HttpServer", methodName : "uploadFile"}); - var _this = _gthis.uploadingFiles; - if(Object.prototype.hasOwnProperty.call(_this.h,name)) { - delete(_this.h[name]); + haxe_Log.trace(err,{ fileName : "src/server/HttpServer.hx", lineNumber : 197, className : "server.HttpServer", methodName : "uploadFile"}); + res.statusCode = 500; + res.end(JSON.stringify({ info : "File write stream error."})); + var _this = _gthis.uploadingFilesSizes; + if(Object.prototype.hasOwnProperty.call(_this.h,filePath)) { + delete(_this.h[filePath]); } var _this = _gthis.uploadingFilesLastChunks; - if(Object.prototype.hasOwnProperty.call(_this.h,name)) { - delete(_this.h[name]); + if(Object.prototype.hasOwnProperty.call(_this.h,filePath)) { + delete(_this.h[filePath]); } - res.statusCode = 200; - res.end(JSON.stringify({ info : "File write stream error."})); + _gthis.cache.remove(name); }); req.on("error",function(err) { - haxe_Log.trace("Request Error:",{ fileName : "src/server/HttpServer.hx", lineNumber : 216, className : "server.HttpServer", methodName : "uploadFile", customParams : [err]}); + haxe_Log.trace("Request Error:",{ fileName : "src/server/HttpServer.hx", lineNumber : 204, className : "server.HttpServer", methodName : "uploadFile", customParams : [err]}); stream.destroy(); - var _this = _gthis.uploadingFiles; - if(Object.prototype.hasOwnProperty.call(_this.h,name)) { - delete(_this.h[name]); + res.statusCode = 500; + res.end(JSON.stringify({ info : "File request error."})); + var _this = _gthis.uploadingFilesSizes; + if(Object.prototype.hasOwnProperty.call(_this.h,filePath)) { + delete(_this.h[filePath]); } var _this = _gthis.uploadingFilesLastChunks; - if(Object.prototype.hasOwnProperty.call(_this.h,name)) { - delete(_this.h[name]); + if(Object.prototype.hasOwnProperty.call(_this.h,filePath)) { + delete(_this.h[filePath]); } - res.statusCode = 200; - res.end(JSON.stringify({ info : "File request error."})); + _gthis.cache.remove(name); }); } ,getPath: function(dir,url) { @@ -4326,7 +4358,7 @@ server_HttpServer.prototype = { return haxe_io_Path.addTrailingSlash(filePath) + "index.html"; } ,readFileError: function(err,res,filePath) { - res.setHeader("Content-Type",this.getMimeType("html")); + res.setHeader("content-type",this.getMimeType("html")); if(err.code == "ENOENT") { res.statusCode = 404; var rel = js_node_Path.relative(this.dir,filePath); @@ -4341,13 +4373,13 @@ server_HttpServer.prototype = { return false; } var videoSize = js_node_Fs.statSync(filePath).size; - if(Object.prototype.hasOwnProperty.call(this.uploadingFiles.h,filePath)) { - videoSize = this.uploadingFiles.h[filePath]; + if(Object.prototype.hasOwnProperty.call(this.uploadingFilesSizes.h,filePath)) { + videoSize = this.uploadingFilesSizes.h[filePath]; } var rangeHeader = req.headers["range"]; if(rangeHeader == null) { res.statusCode = 200; - res.setHeader("Content-Length","" + videoSize); + res.setHeader("content-length","" + videoSize); var videoStream = js_node_Fs.createReadStream(filePath); videoStream.pipe(res); res.on("error",function() { @@ -4362,8 +4394,8 @@ server_HttpServer.prototype = { var start = range.start; var end = range.end; var contentLength = end - start + 1; - res.setHeader("Content-Range","bytes " + start + "-" + end + "/" + videoSize); - res.setHeader("Content-Length","" + contentLength); + res.setHeader("content-range","bytes " + start + "-" + end + "/" + videoSize); + res.setHeader("content-length","" + contentLength); res.statusCode = 206; var buffer = this.uploadingFilesLastChunks.h[filePath]; if(buffer != null && end == videoSize - 1 && contentLength < buffer.byteLength) { @@ -5209,6 +5241,7 @@ server_Main.prototype = { result[i] = { name : client1.name, id : client1.id, ip : _gthis.clientIp(client1.req), isBanned : (client1.group & 1) != 0, isAdmin : (client1.group & 8) != 0, isLeader : (client1.group & 4) != 0, isUser : (client1.group & 2) != 0}; } var json = server_Main.jsonStringify({ state : data1, clients : result, logs : this.logger.getLogs()},"\t"); + this.send(client,{ type : "ServerMessage", serverMessage : { textId : "Free space: " + tools_MathTools.toFixed(this.cache.getFreeSpace() / 1024) + "KiB"}}); this.send(client,{ type : "Dump", dump : { data : json}}); break; case "Flashback": @@ -5298,7 +5331,7 @@ server_Main.prototype = { this.send(client,{ type : "LoginError"}); return; } - haxe_Log.trace(HxOverrides.dateStr(new Date()),{ fileName : "src/server/Main.hx", lineNumber : 607, 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 : 606, className : "server.Main", methodName : "onMessage", customParams : ["Client " + client.name + " logged as " + name]}); client.name = name; client.setGroupFlag(ClientGroup.User,true); this.checkBan(client); @@ -5311,7 +5344,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 : 628, 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 : 627, 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; @@ -5373,11 +5406,15 @@ server_Main.prototype = { if(!this.checkPermission(client,"changeOrder")) { return; } + var pos = data.playItem.pos; + if(!this.videoList.hasItem(pos)) { + return; + } if(this.videoTimer.getTime() > 30) { var _this = this.videoList; this.saveFlashbackTime(_this.items[_this.pos]); } - this.videoList.setPos(data.playItem.pos); + this.videoList.setPos(pos); data.playItem.pos = this.videoList.pos; this.restartWaitTimer(); this.broadcast(data); @@ -5408,11 +5445,9 @@ server_Main.prototype = { this.saveFlashbackTime(_this.items[_this.pos]); } this.videoList.removeItem(index); + this.broadcast(data); if(isCurrent && this.videoList.items.length > 0) { - this.broadcast(data); this.restartWaitTimer(); - } else { - this.broadcast(data); } break; case "Rewind": @@ -5466,6 +5501,9 @@ server_Main.prototype = { return; } var pos = data.setNextItem.pos; + if(!this.videoList.hasItem(pos)) { + return; + } if(pos == this.videoList.pos || pos == this.videoList.pos + 1) { return; } @@ -5522,7 +5560,11 @@ server_Main.prototype = { if(!this.checkPermission(client,"toggleItemType")) { return; } - this.videoList.toggleItemType(data.toggleItemType.pos); + var pos = data.toggleItemType.pos; + if(!this.videoList.hasItem(pos)) { + return; + } + this.videoList.toggleItemType(pos); this.broadcast(data); break; case "TogglePlaylistLock": @@ -5539,6 +5581,9 @@ server_Main.prototype = { this.broadcast({ type : "UpdatePlaylist", updatePlaylist : { videoList : this.videoList.items}}); break; case "VideoLoaded": + if(this.isServerPause) { + return; + } this.prepareVideoPlayback(); break; } @@ -5618,11 +5663,10 @@ server_Main.prototype = { } var ip = this.clientIp(client.req); var currentTime = new Date().getTime(); - var _g = 0; - var _g1 = this.userList.bans; - while(_g < _g1.length) { - var ban = _g1[_g]; - ++_g; + var arr = this.userList.bans; + var _g_i = arr.length - 1; + while(_g_i > -1) { + var ban = arr[_g_i--]; if(ban.ip != ip) { continue; } @@ -5630,7 +5674,7 @@ 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 : 1056, className : "server.Main", methodName : "checkBan"}); + haxe_Log.trace("" + client.name + " ban removed",{ fileName : "src/server/Main.hx", lineNumber : 1062, className : "server.Main", methodName : "checkBan"}); this.sendClientList(); } break; @@ -5869,6 +5913,12 @@ server_VideoTimer.prototype = { this.pauseStartTime = 0; } ,pause: function() { + if(this.isPaused()) { + return; + } + this.updatePauseTime(); + } + ,updatePauseTime: function() { this.startTime += this.rateTime() - this.rateTime() * this.rate; var hrtime = process.hrtime(); this.pauseStartTime = hrtime[0] + hrtime[1] / 1e9; @@ -5896,7 +5946,7 @@ server_VideoTimer.prototype = { var hrtime = process.hrtime(); this.rateStartTime = hrtime[0] + hrtime[1] / 1e9; if(this.isPaused()) { - this.pause(); + this.updatePauseTime(); } } ,isPaused: function() { diff --git a/res/client.js b/res/client.js index 9629980..a7560d6 100644 --- a/res/client.js +++ b/res/client.js @@ -798,41 +798,49 @@ client_Buttons.init = function(main) { if(name == null || name.length == 0) { name = "video"; } - if(buffer.byteLength > 5242880) { - var lastChunk = buffer.slice(buffer.byteLength - 5242880); - window.fetch("/upload-last-chunk",{ method : "POST", headers : { "content-name" : haxe_io_Path.withoutExtension(name), "client-name" : main.personal.name}, body : lastChunk}); - } - var request = window.fetch("/upload",{ method : "POST", headers : { "content-name" : haxe_io_Path.withoutExtension(name), "client-name" : main.personal.name}, body : buffer}); - request.then(function(e) { + var a = buffer.byteLength - 5242880; + var lastChunk = buffer.slice(a < 0 ? 0 : a); + var chunkReq = window.fetch("/upload-last-chunk",{ method : "POST", headers : { "content-name" : name, "client-name" : main.personal.name}, body : lastChunk}); + chunkReq.then(function(e) { return e.json().then(function(data) { - haxe_Log.trace(data.info,{ fileName : "src/client/Buttons.hx", lineNumber : 276, className : "client.Buttons", methodName : "init"}); - if(data.errorId == null) { + if(data.errorId != null) { + main.serverMessage(data.info,true,false); return; } - main.serverMessage(data.info,true,false); + window.document.querySelector("#mediaurl").value = data.url; }); - }).catch(function(err) { - haxe_Log.trace(err,{ fileName : "src/client/Buttons.hx", lineNumber : 281, className : "client.Buttons", methodName : "init"}); - return haxe_Timer.delay(function() { - main.hideDynamicChin(); - },500); }); - var onStartUpload = null; - onStartUpload = function(event) { - if(event.type != "Progress") { - return; + var request = new XMLHttpRequest(); + request.open("POST","/upload",true); + request.setRequestHeader("content-name",name); + request.setRequestHeader("client-name",main.personal.name); + request.upload.onprogress = function(event) { + var ratio = 0.0; + if(event.lengthComputable) { + var v = event.loaded / event.total; + ratio = v < 0 ? 0 : v > 1 ? 1 : v; } - var data = event.progress; - if(data.type != "Uploading") { + main.onProgressEvent({ type : "Progress", progress : { type : "Uploading", ratio : ratio}}); + }; + request.onload = function(e) { + var data; + try { + data = JSON.parse(request.responseText); + } catch( _g ) { + haxe_Log.trace(haxe_Exception.caught(_g),{ fileName : "src/client/Buttons.hx", lineNumber : 299, className : "client.Buttons", methodName : "init"}); return; } - if(data.data == null) { + if(data.errorId == null) { return; } - window.document.querySelector("#mediaurl").value = data.data; - client_JsApi.off("Progress",onStartUpload); + main.serverMessage(data.info,true,false); + }; + request.onloadend = function() { + return haxe_Timer.delay(function() { + main.hideDynamicChin(); + },500); }; - client_JsApi.on("Progress",onStartUpload); + request.send(new Blob([buffer])); }); }; var showOptions = window.document.querySelector("#showoptions"); @@ -1267,18 +1275,6 @@ client_JsApi.hasSubtitleSupport = $hx_exports["client"]["JsApi"]["hasSubtitleSup client_JsApi.once = $hx_exports["client"]["JsApi"]["once"] = function(type,callback) { client_JsApi.onceListeners.unshift({ type : type, callback : callback}); }; -client_JsApi.on = function(type,callback) { - client_JsApi.onListeners.unshift({ type : type, callback : callback}); -}; -client_JsApi.off = function(type,callback) { - HxOverrides.remove(client_JsApi.onListeners,Lambda.find(client_JsApi.onListeners,function(item) { - if(item.type == type) { - return item.callback == callback; - } else { - return false; - } - })); -}; client_JsApi.fireEvents = function(event) { var _g_arr = client_JsApi.onListeners; var _g_i = _g_arr.length - 1; @@ -1385,7 +1381,7 @@ client_Main.prototype = { } this.gotFirstPageInteraction = true; this.player.unmute(); - if(!this.hasLeader() && !this.showingServerPause) { + if(!this.hasLeader() && !this.showingServerPause && !this.player.inUserInteraction) { this.player.play(); } window.document.removeEventListener("click",$bind(this,this.onFirstInteraction)); @@ -1678,7 +1674,6 @@ client_Main.prototype = { } } ,onMessage: function(e) { - var _gthis = this; var data = JSON.parse(e.data); if(this.config != null && this.config.isVerbose) { var t = data.type; @@ -1830,30 +1825,7 @@ client_Main.prototype = { this.player.setVideo(data.playItem.pos); break; case "Progress": - var data1 = data.progress; - var text; - switch(data1.type) { - case "Caching": - text = "" + Lang.get("caching") + " " + data1.data; - break; - case "Downloading": - text = Lang.get("downloading"); - break; - case "Uploading": - text = Lang.get("uploading"); - break; - } - var percent = tools_MathTools.toFixed(data1.ratio * 100,1); - var text1 = "" + text + "..."; - if(percent > 0) { - text1 += " " + percent + "%"; - } - this.showProgressInfo(text1); - if(data1.ratio == 1) { - haxe_Timer.delay(function() { - _gthis.hideDynamicChin(); - },500); - } + this.onProgressEvent(data); break; case "RemoveVideo": this.player.removeItem(data.removeVideo.url); @@ -1935,6 +1907,33 @@ client_Main.prototype = { break; } } + ,onProgressEvent: function(data) { + var _gthis = this; + var data1 = data.progress; + var text; + switch(data1.type) { + case "Caching": + text = "" + Lang.get("caching") + " " + data1.data; + break; + case "Downloading": + text = Lang.get("downloading"); + break; + case "Uploading": + text = Lang.get("uploading"); + break; + } + var percent = tools_MathTools.toFixed(data1.ratio * 100,1); + var text1 = "" + text + "..."; + if(percent > 0) { + text1 += " " + percent + "%"; + } + this.showProgressInfo(text1); + if(data1.ratio == 1) { + haxe_Timer.delay(function() { + _gthis.hideDynamicChin(); + },500); + } + } ,updateLastStateTime: function() { if(this.lastStateTimeStamp == 0) { this.lastStateTimeStamp = HxOverrides.now() / 1000; @@ -2918,7 +2917,14 @@ client_Player.prototype = { } var hasAutoPause = this.main.hasLeaderOnPauseRequest(); if((this.main.personal.group & 4) == 0) { - if(hasAutoPause && this.inUserInteraction || this.main.hasUnpauseWithoutLeader()) { + var allowUnpause = hasAutoPause && this.inUserInteraction; + if(!allowUnpause) { + allowUnpause = this.main.hasUnpauseWithoutLeader(); + } + if(this.getPlaybackRate() != 1) { + allowUnpause = false; + } + if(allowUnpause) { this.main.removeLeader(); } else if(this.main.lastState.paused) { this.pause(); @@ -2928,8 +2934,8 @@ client_Player.prototype = { } this.main.send({ type : "Play", play : { time : this.getTime()}}); if(hasAutoPause) { - if(this.main.hasPermission("requestLeader")) { - this.main.toggleLeader(); + if(this.main.hasPermission("requestLeader") && this.getPlaybackRate() == 1) { + this.main.removeLeader(); } } } @@ -3330,7 +3336,7 @@ client_Player.prototype = { } }; http.onError = function(msg) { - haxe_Log.trace(msg,{ fileName : "src/client/Player.hx", lineNumber : 656, className : "client.Player", methodName : "skipAd"}); + haxe_Log.trace(msg,{ fileName : "src/client/Player.hx", lineNumber : 661, className : "client.Player", methodName : "skipAd"}); }; http.request(); } diff --git a/src/VideoList.hx b/src/VideoList.hx index c5e92c8..eb3e67d 100644 --- a/src/VideoList.hx +++ b/src/VideoList.hx @@ -44,6 +44,10 @@ class VideoList { pos = i; } + public function hasItem(i:Int):Bool { + return items[i] != null; + } + public function exists(f:(item:VideoItem) -> Bool):Bool { return items.exists(f); } diff --git a/src/client/Buttons.hx b/src/client/Buttons.hx index 99e12cb..513133a 100644 --- a/src/client/Buttons.hx +++ b/src/client/Buttons.hx @@ -1,18 +1,20 @@ package client; import Types.UploadResponse; -import Types.WsEvent; import client.Main.getEl; +import haxe.Json; import haxe.Timer; -import haxe.io.Path; import js.Browser.document; import js.Browser.window; +import js.html.Blob; import js.html.Element; import js.html.ImageElement; import js.html.InputElement; import js.html.KeyboardEvent; +import js.html.ProgressEvent; import js.html.TransitionEvent; import js.html.VisualViewport; +import js.html.XMLHttpRequest; class Buttons { static var split:Split; @@ -250,51 +252,63 @@ class Buttons { // send last chunk separately to allow server file streaming while uploading final chunkSize = 1024 * 1024 * 5; // 5 MB - if (buffer.byteLength > chunkSize) { - final lastChunk = buffer.slice(buffer.byteLength - chunkSize); - window.fetch("/upload-last-chunk", { - method: "POST", - headers: { - "content-name": Path.withoutExtension(name), - "client-name": main.getName(), - }, - body: lastChunk, - }); - } - - // send full file - final request = window.fetch("/upload", { + final bufferOffset = (buffer.byteLength - chunkSize).limitMin(0); + final lastChunk = buffer.slice(bufferOffset); + final chunkReq = window.fetch("/upload-last-chunk", { method: "POST", headers: { - "content-name": Path.withoutExtension(name), + "content-name": name, "client-name": main.getName(), }, - body: buffer, + body: lastChunk, }); - request.then(e -> { + chunkReq.then(e -> { e.json().then((data:UploadResponse) -> { - trace(data.info); - if (data.errorId == null) return; - main.serverMessage(data.info, true, false); + if (data.errorId != null) { + main.serverMessage(data.info, true, false); + return; + } + final input:InputElement = getEl("#mediaurl"); + input.value = data.url; + }); + }); + + final request = new XMLHttpRequest(); + request.open("POST", "/upload", true); + request.setRequestHeader("content-name", name); + request.setRequestHeader("client-name", main.getName()); + + request.upload.onprogress = (event:ProgressEvent) -> { + var ratio = 0.0; + if (event.lengthComputable) { + ratio = (event.loaded / event.total).clamp(0, 1); + } + main.onProgressEvent({ + type: Progress, + progress: { + type: Uploading, + ratio: ratio + } }); - }).catchError(err -> { - trace(err); + } + + request.onload = (e:ProgressEvent) -> { + final data:UploadResponse = try { + Json.parse(request.responseText); + } catch (e) { + trace(e); + return; + } + if (data.errorId == null) return; + main.serverMessage(data.info, true, false); + } + request.onloadend = () -> { Timer.delay(() -> { main.hideDynamicChin(); }, 500); - }); - - // set file url to input after upload starts - function onStartUpload(event:WsEvent):Void { - if (event.type != Progress) return; - final data = event.progress; - if (data.type != Uploading) return; - if (data.data == null) return; - final input:InputElement = getEl("#mediaurl"); - input.value = data.data; - JsApi.off(Progress, onStartUpload); } - JsApi.on(Progress, onStartUpload); + + request.send(new Blob([buffer])); }); } diff --git a/src/client/Main.hx b/src/client/Main.hx index 84f6838..5c4b28d 100644 --- a/src/client/Main.hx +++ b/src/client/Main.hx @@ -120,7 +120,7 @@ class Main { if (!player.isVideoLoaded()) return; gotFirstPageInteraction = true; player.unmute(); - if (!hasLeader() && !showingServerPause) player.play(); + if (!hasLeader() && !showingServerPause && !player.inUserInteraction) player.play(); document.removeEventListener("click", onFirstInteraction); } @@ -508,24 +508,7 @@ class Main { serverMessage(text); case Progress: - final data = data.progress; - final text = switch data.type { - case Caching: - final caching = Lang.get("caching"); - final name = data.data; - '$caching $name'; - case Downloading: Lang.get("downloading"); - case Uploading: Lang.get("uploading"); - } - final percent = (data.ratio * 100).toFixed(1); - var text = '$text...'; - if (percent > 0) text += ' $percent%'; - showProgressInfo(text); - if (data.ratio == 1) { - Timer.delay(() -> { - hideDynamicChin(); - }, 500); - } + onProgressEvent(data); case AddVideo: player.addVideoItem(data.addVideo.item, data.addVideo.atEnd); @@ -675,6 +658,27 @@ class Main { } } + public function onProgressEvent(data:WsEvent):Void { + final data = data.progress; + final text = switch data.type { + case Caching: + final caching = Lang.get("caching"); + final name = data.data; + '$caching $name'; + case Downloading: Lang.get("downloading"); + case Uploading: Lang.get("uploading"); + } + final percent = (data.ratio * 100).toFixed(1); + var text = '$text...'; + if (percent > 0) text += ' $percent%'; + showProgressInfo(text); + if (data.ratio == 1) { + Timer.delay(() -> { + hideDynamicChin(); + }, 500); + } + } + function updateLastStateTime():Void { if (lastStateTimeStamp == 0) { lastStateTimeStamp = Timer.stamp(); diff --git a/src/client/Player.hx b/src/client/Player.hx index e5ea87c..64248fe 100644 --- a/src/client/Player.hx +++ b/src/client/Player.hx @@ -279,8 +279,11 @@ class Player { if (!main.isLeader()) { // user click, so we can unpause by removing leader // (doesn't work in Firefox because of no video click propagation) - final allowUnpause = (hasAutoPause && inUserInteraction); - if (allowUnpause || main.hasUnpauseWithoutLeader()) { + var allowUnpause = hasAutoPause && inUserInteraction; + if (!allowUnpause) allowUnpause = main.hasUnpauseWithoutLeader(); + // do not remove leader with custom rate + if (getPlaybackRate() != 1) allowUnpause = false; + if (allowUnpause) { main.removeLeader(); } else { // paused and no leader - instant pause @@ -299,7 +302,9 @@ class Player { }); if (hasAutoPause) { // do not remove leader if user cannot request it back - if (main.hasPermission(RequestLeaderPerm)) main.toggleLeader(); + if (main.hasPermission(RequestLeaderPerm) && getPlaybackRate() == 1) { + main.removeLeader(); + } } } diff --git a/src/server/Cache.hx b/src/server/Cache.hx index fa5889c..d772648 100644 --- a/src/server/Cache.hx +++ b/src/server/Cache.hx @@ -17,7 +17,8 @@ class Cache { public final isYtReady = false; /** In bytes **/ - var storageLimit = 3 * 1024 * 1024 * 1024; + public var storageLimit(default, null) = 3 * 1024 * 1024 * 1024; + final freeSpaceBlock = 10 * 1024 * 1024; // 10MB public function new(main:Main, cacheDir:String) { @@ -25,6 +26,7 @@ class Cache { this.cacheDir = cacheDir; Utils.ensureDir(cacheDir); isYtReady = checkYtDeps(); + if (isYtReady) cleanYtInputFiles(); } function checkYtDeps():Bool { @@ -41,6 +43,14 @@ class Cache { } } + function cleanYtInputFiles():Void { + final names = FileSystem.readDirectory(cacheDir); + for (name in names) { + if (!name.startsWith("__tmp")) continue; + remove(name); + } + } + function log(client:Client, msg:String):Void { main.serverMessage(client, msg); trace(msg); @@ -61,6 +71,16 @@ class Cache { callback(outName); return; } + final inVideoName = '__tmp-video-$videoId'; + final inAudioName = '__tmp-audio-$videoId'; + inline function removeInputFiles():Void { + remove(inVideoName); + remove(inAudioName); + } + if (isFileExists(inVideoName)) { + log(client, 'Caching $outName already in progress'); + return; + } final ytdl:Dynamic = untyped require("@distube/ytdl-core"); trace('Caching $url to $outName...'); main.send(client, { @@ -93,26 +113,36 @@ class Cache { final dlVideo:Readable = ytdl(url, { format: videoFormat, }); - dlVideo.pipe(Fs.createWriteStream('$cacheDir/input-video')); - dlVideo.on("error", err -> log(client, "Error during video download: " + err)); + dlVideo.pipe(Fs.createWriteStream('$cacheDir/$inVideoName')); + dlVideo.on("error", err -> { + log(client, "Error during video download: " + err); + removeInputFiles(); + }); final dlAudio:Readable = ytdl(url, { format: audioFormat, }); - dlAudio.pipe(Fs.createWriteStream('$cacheDir/input-audio')); - dlAudio.on("error", err -> log(client, "Error during audio download: " + err)); + dlAudio.pipe(Fs.createWriteStream('$cacheDir/$inAudioName')); + dlAudio.on("error", err -> { + log(client, "Error during audio download: " + err); + removeInputFiles(); + }); var count = 0; function onComplete(type:String):Void { count++; trace('$type track downloaded ($count/2)'); if (count < 2) return; - var size = FileSystem.stat('$cacheDir/input-video').size; - size += FileSystem.stat('$cacheDir/input-audio').size; + if (!isFileExists(inVideoName) || !isFileExists(inAudioName)) { + removeInputFiles(); + return; + } + var size = FileSystem.stat('$cacheDir/$inVideoName').size; + size += FileSystem.stat('$cacheDir/$inAudioName').size; // clean some space for full mp4 removeOlderCache(size + freeSpaceBlock); - final args = '-y -i input-video -i input-audio -c copy -map 0:v -map 1:a ./$outName'.split(" "); + 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, stdio: "ignore" @@ -121,15 +151,11 @@ class Cache { // trace('FFmpeg stderr: ${data}'); // }); process.on("close", (code:Int) -> { + removeInputFiles(); if (code != 0) { log(client, 'Error: ffmpeg closed with code $code'); return; } - final inVideo = '$cacheDir/input-video'; - final inAudio = '$cacheDir/input-audio'; - FileSystem.deleteFile(inVideo); - FileSystem.deleteFile(inAudio); - add(outName); callback(outName); @@ -160,6 +186,7 @@ class Cache { }); }); }).catchError(err -> { + removeInputFiles(); log(client, "" + err); }); } @@ -191,21 +218,32 @@ class Cache { } } + public function remove(name:String):Void { + cachedFiles.remove(name); + removeFile(name); + } + public function removeOlderCache(addFileSize = 0):Void { var space = getUsedSpace(addFileSize); while (space > storageLimit) { final name = cachedFiles.pop() ?? break; - final path = getFilePath(name); - if (FileSystem.exists(path)) FileSystem.deleteFile(path); + removeFile(name); space = getUsedSpace(addFileSize); } } - public function getFreeFileName(baseName = "video"):String { + 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.mp4'; + final name = '$baseName$n.$ext'; if (!isFileExists(name)) return name; i++; } diff --git a/src/server/HttpServer.hx b/src/server/HttpServer.hx index 6602e86..346aac1 100644 --- a/src/server/HttpServer.hx +++ b/src/server/HttpServer.hx @@ -54,7 +54,8 @@ class HttpServer { final allowLocalRequests = false; final cache:Cache = null; final CHUNK_SIZE = 1024 * 1024 * 5; // 5 MB - final uploadingFiles:Map = []; + // temp media data while file is uploading to allow instant streaming + final uploadingFilesSizes:Map = []; final uploadingFilesLastChunks:Map = []; public function new(main:Main, config:HttpServerConfig):Void { @@ -76,8 +77,8 @@ class HttpServer { var filePath = getPath(dir, url); final ext = Path.extension(filePath).toLowerCase(); - res.setHeader("Accept-Ranges", "bytes"); - res.setHeader("Content-Type", getMimeType(ext)); + res.setHeader("accept-ranges", "bytes"); + res.setHeader("content-type", getMimeType(ext)); if (cache != null && req.method == "POST") { switch url.pathname { @@ -140,10 +141,11 @@ class HttpServer { final buffer = Buffer.concat(body); uploadingFilesLastChunks[filePath] = buffer; res.writeHead(200, { - 'Content-Type': 'application/json', + "content-type": getMimeType("json"), }); final json:UploadResponse = { - info: "File last chunk uploaded" + info: "File last chunk uploaded", + url: cache.getFileUrl(name) } res.end(Json.stringify(json)); }); @@ -154,70 +156,57 @@ class HttpServer { final clientName = req.headers["client-name"]; final filePath = cache.getFilePath(name); final size = Std.parseInt(req.headers["content-length"]) ?? return; - var written = 0; - inline function end(json:UploadResponse):Void { - uploadingFiles.remove(name); - uploadingFilesLastChunks.remove(name); - - res.statusCode = 200; + inline function end(code:Int, json:UploadResponse):Void { + res.statusCode = code; res.end(Json.stringify(json)); + + uploadingFilesSizes.remove(filePath); + uploadingFilesLastChunks.remove(filePath); } + if (size < cache.storageLimit) { + // do not remove older cache if file is out of limit anyway + cache.removeOlderCache(size); + } if (cache.getFreeSpace() < size) { - end({ - info: "Error: Not enough free space on server or file size is out of cache storage limit.", - errorId: "freeSpace" + final errText = "Error: Not enough free space on server or file size is out of cache storage limit."; + end(413, { // Payload Too Large + info: errText, + errorId: "freeSpace", }); + cache.remove(name); + req.destroy(); + final client = main.clients.getByName(name) ?? return; + main.serverMessage(client, errText); return; } final stream = Fs.createWriteStream(filePath); req.pipe(stream); - inline function onStart() { - cache.removeOlderCache(size); - cache.add(name); - uploadingFiles[filePath] = size; - } - var isStart = true; - req.on("data", chunk -> { - var url:String = null; - if (isStart) { - isStart = false; - onStart(); - url = cache.getFileUrl(name); - } - written += chunk.length; - final ratio = (written / size).clamp(0, 1); - final percent = (ratio * 100).toFixed(2); - final client = main.clients.getByName(clientName) ?? return; - main.send(client, { - type: Progress, - progress: { - type: Uploading, - ratio: ratio, - data: url - } - }); - }); + cache.add(name); + uploadingFilesSizes[filePath] = size; + stream.on("close", () -> { - end({ + end(200, { info: "File write stream closed.", }); }); stream.on("error", err -> { trace(err); - end({ + end(500, { info: "File write stream error.", }); + cache.remove(name); }); req.on("error", err -> { trace("Request Error:", err); stream.destroy(); - end({ + end(500, { info: "File request error.", }); + cache.remove(name); }); } @@ -229,7 +218,7 @@ class HttpServer { } function readFileError(err:Dynamic, res:ServerResponse, filePath:String):Void { - res.setHeader("Content-Type", getMimeType("html")); + res.setHeader("content-type", getMimeType("html")); if (err.code == "ENOENT") { res.statusCode = 404; var rel = JsPath.relative(dir, filePath); @@ -244,13 +233,13 @@ class HttpServer { if (!Fs.existsSync(filePath)) return false; var videoSize:Int = cast Fs.statSync(filePath).size; // use future content length to start playing it before uploaded - if (uploadingFiles.exists(filePath)) { - videoSize = uploadingFiles[filePath]; + if (uploadingFilesSizes.exists(filePath)) { + videoSize = uploadingFilesSizes[filePath]; } final rangeHeader:String = req.headers["range"]; if (rangeHeader == null) { res.statusCode = 200; - res.setHeader("Content-Length", '$videoSize'); + res.setHeader("content-length", '$videoSize'); final videoStream = Fs.createReadStream(filePath); videoStream.pipe(res); res.on("error", () -> videoStream.destroy()); @@ -262,8 +251,8 @@ class HttpServer { final end = range.end; final contentLength = end - start + 1; - res.setHeader("Content-Range", 'bytes $start-$end/$videoSize'); - res.setHeader("Content-Length", '$contentLength'); + res.setHeader("content-range", 'bytes $start-$end/$videoSize'); + res.setHeader("content-length", '$contentLength'); res.statusCode = 206; // partial content // check for last chunk cache for instant play while uploading @@ -368,7 +357,7 @@ class HttpServer { function isChildOf(parent:String, child:String):Bool { final rel = JsPath.relative(parent, child); - return rel.length > 0 && !rel.startsWith('..') && !JsPath.isAbsolute(rel); + return rel.length > 0 && !rel.startsWith("..") && !JsPath.isAbsolute(rel); } function getMimeType(ext:String):String { diff --git a/src/server/Main.hx b/src/server/Main.hx index d5c0800..644a8b1 100644 --- a/src/server/Main.hx +++ b/src/server/Main.hx @@ -517,7 +517,6 @@ class Main { clients.remove(client); sendClientList(); if (client.isLeader) { - // if (videoTimer.isPaused()) videoTimer.play(); if (videoList.length > 0) { videoTimer.pause(); isServerPause = true; @@ -700,6 +699,7 @@ class Main { case VideoLoaded: // Called if client loads next video and can play it + if (isServerPause) return; prepareVideoPlayback(); case RemoveVideo: @@ -715,12 +715,8 @@ class Main { saveFlashbackTime(videoList.currentItem); } videoList.removeItem(index); - if (isCurrent && videoList.length > 0) { - broadcast(data); - restartWaitTimer(); - } else { - broadcast(data); - } + broadcast(data); + if (isCurrent && videoList.length > 0) restartWaitTimer(); case SkipVideo: if (!checkPermission(client, RemoveVideoPerm)) return; @@ -857,10 +853,12 @@ class Main { case PlayItem: if (!checkPermission(client, ChangeOrderPerm)) return; + final pos = data.playItem.pos; + if (!videoList.hasItem(pos)) return; if (videoTimer.getTime() > FLASHBACK_DIST) { saveFlashbackTime(videoList.currentItem); } - videoList.setPos(data.playItem.pos); + videoList.setPos(pos); data.playItem.pos = videoList.pos; restartWaitTimer(); broadcast(data); @@ -869,6 +867,7 @@ class Main { if (isPlaylistLockedFor(client)) return; if (!checkPermission(client, ChangeOrderPerm)) return; final pos = data.setNextItem.pos; + if (!videoList.hasItem(pos)) return; if (pos == videoList.pos || pos == videoList.pos + 1) return; videoList.setNextItem(pos); broadcast(data); @@ -877,6 +876,7 @@ class Main { if (isPlaylistLockedFor(client)) return; if (!checkPermission(client, ToggleItemTypePerm)) return; final pos = data.toggleItemType.pos; + if (!videoList.hasItem(pos)) return; videoList.toggleItemType(pos); broadcast(data); @@ -941,6 +941,12 @@ class Main { logs: logger.getLogs() } final json = jsonStringify(data, "\t"); + send(client, { + type: ServerMessage, + serverMessage: { + textId: "Free space: " + (cache.getFreeSpace() / 1024).toFixed() + "KiB" + } + }); send(client, { type: Dump, dump: { @@ -1047,7 +1053,7 @@ class Main { } final ip = clientIp(client.req); final currentTime = Date.now().getTime(); - for (ban in userList.bans) { + for (ban in userList.bans.reversed()) { if (ban.ip != ip) continue; final isOutdated = ban.toDate.getTime() < currentTime; client.isBanned = !isOutdated; diff --git a/src/server/VideoTimer.hx b/src/server/VideoTimer.hx index fcbb461..311e7f4 100644 --- a/src/server/VideoTimer.hx +++ b/src/server/VideoTimer.hx @@ -26,6 +26,11 @@ class VideoTimer { } public function pause():Void { + if (isPaused()) return; + updatePauseTime(); + } + + function updatePauseTime():Void { startTime += rateTime() - rateTime() * this.rate; pauseStartTime = stamp(); rateStartTime = 0; @@ -47,7 +52,7 @@ class VideoTimer { public function setTime(secs:Float):Void { startTime = stamp() - secs; rateStartTime = stamp(); - if (isPaused()) pause(); + if (isPaused()) updatePauseTime(); } public function isPaused():Bool { diff --git a/test/tests/TestServer.hx b/test/tests/TestServer.hx index abdbf8f..b02bb90 100644 --- a/test/tests/TestServer.hx +++ b/test/tests/TestServer.hx @@ -67,10 +67,6 @@ class TestServer extends Test { server.onServerInited = () -> { final client = new FakeClient(server.localIp, server.port); var client2:FakeClient = null; - // client.ws.on("message", data -> { - // final data:WsEvent = Json.parse(data); - // trace(data.type); - // }); client.message().then((data) -> { Assert.equals(Connected, data.type); diff --git a/test/tests/TestTimer.hx b/test/tests/TestTimer.hx index 1261553..2e6e9e8 100644 --- a/test/tests/TestTimer.hx +++ b/test/tests/TestTimer.hx @@ -136,6 +136,18 @@ class TestTimer extends Test { }, 400); } + @:timeout(500) + function testDoublePause(async:Async) { + final timer = new VideoTimer(); + timer.start(); + timer.pause(); + Timer.delay(() -> { + timer.pause(); + almostEq(0, timer.getTime()); + async.done(); + }, 300); + } + @:timeout(500) function testBigRate(async:Async) { final timer = new VideoTimer();