diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/VideoList.hx | 4 | ||||
| -rw-r--r-- | src/client/Buttons.hx | 86 | ||||
| -rw-r--r-- | src/client/Main.hx | 42 | ||||
| -rw-r--r-- | src/client/Player.hx | 11 | ||||
| -rw-r--r-- | src/server/Cache.hx | 72 | ||||
| -rw-r--r-- | src/server/HttpServer.hx | 89 | ||||
| -rw-r--r-- | src/server/Main.hx | 24 | ||||
| -rw-r--r-- | src/server/VideoTimer.hx | 7 |
8 files changed, 200 insertions, 135 deletions
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<Dynamic> = 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<Dynamic> = 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<String, Int> = []; + // temp media data while file is uploading to allow instant streaming + final uploadingFilesSizes:Map<String, Int> = []; final uploadingFilesLastChunks:Map<String, Buffer> = []; 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); @@ -942,6 +942,12 @@ class Main { } final json = jsonStringify(data, "\t"); send(client, { + type: ServerMessage, + serverMessage: { + textId: "Free space: " + (cache.getFreeSpace() / 1024).toFixed() + "KiB" + } + }); + send(client, { type: Dump, dump: { data: json @@ -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 { |
