aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authorRblSb <msrblsb@gmail.com>2025-02-06 06:41:49 +0300
committerRblSb <msrblsb@gmail.com>2025-02-07 01:12:14 +0300
commitd86f0c30e1726a56e670955c3b995945c1daf834 (patch)
tree2cff6b1c76191df76291f93c5a46810f09181727 /src
parent382f9b2ebedca905028341825350a0fa69d88673 (diff)
Fixes pack
- Fix timer seek on server pause with double timer.pause() calls - Implement multi-caching - Better uploading progress with XMLHttpRequest - Better upload/cache error reporting
Diffstat (limited to 'src')
-rw-r--r--src/VideoList.hx4
-rw-r--r--src/client/Buttons.hx86
-rw-r--r--src/client/Main.hx42
-rw-r--r--src/client/Player.hx11
-rw-r--r--src/server/Cache.hx72
-rw-r--r--src/server/HttpServer.hx89
-rw-r--r--src/server/Main.hx24
-rw-r--r--src/server/VideoTimer.hx7
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 {
send patches to the email below
yukais@pinapelz.com
include the subject [PATCH repo_name]
pinapelz.com
homepage