diff options
| author | RblSb <msrblsb@gmail.com> | 2025-03-25 03:02:03 +0300 |
|---|---|---|
| committer | RblSb <msrblsb@gmail.com> | 2025-03-25 08:16:04 +0300 |
| commit | f874dcd3de368e7e512ab1c0defdd17bc3026ce5 (patch) | |
| tree | 74fecdf08577f2fc03b09b1170b9ffea9163af04 /src | |
| parent | 8ee20a84cb35968f9247028a6a2daa57f04e90bb (diff) | |
Initial cache support for raw videos
m3u8 videos are cached without downloading segments, only m3u8 file is downloaded and segment links are updated to use synctube proxy, so you can add video to playlist as server, ignoring ip restrictions, and stream it to everyone
Diffstat (limited to 'src')
| -rw-r--r-- | src/VideoList.hx | 11 | ||||
| -rw-r--r-- | src/client/Buttons.hx | 21 | ||||
| -rw-r--r-- | src/client/ClientSettings.hx | 31 | ||||
| -rw-r--r-- | src/client/Main.hx | 23 | ||||
| -rw-r--r-- | src/server/ConsoleInput.hx | 2 | ||||
| -rw-r--r-- | src/server/HttpServer.hx | 1 | ||||
| -rw-r--r-- | src/server/Main.hx | 16 | ||||
| -rw-r--r-- | src/server/cache/Cache.hx | 171 | ||||
| -rw-r--r-- | src/server/cache/RawCache.hx | 469 | ||||
| -rw-r--r-- | src/server/cache/YoutubeCache.hx (renamed from src/server/Cache.hx) | 221 |
10 files changed, 759 insertions, 207 deletions
diff --git a/src/VideoList.hx b/src/VideoList.hx index eb3e67d..852c5e5 100644 --- a/src/VideoList.hx +++ b/src/VideoList.hx @@ -52,13 +52,12 @@ class VideoList { return items.exists(f); } + public function find(f:(item:VideoItem) -> Bool):Null<VideoItem> { + return items.find(f); + } + public function findIndex(f:(item:VideoItem) -> Bool):Int { - var i = 0; - for (v in items) { - if (f(v)) return i; - i++; - } - return -1; + return items.findIndex(f); } public function addItem(item:VideoItem, atEnd:Bool):Void { diff --git a/src/client/Buttons.hx b/src/client/Buttons.hx index 11b8f7c..78b1c5a 100644 --- a/src/client/Buttons.hx +++ b/src/client/Buttons.hx @@ -222,6 +222,7 @@ class Buttons { } final mediaUrl:InputElement = getEl("#mediaurl"); + final checkboxCache:InputElement = getEl("#cache-on-server"); mediaUrl.oninput = () -> { final url = mediaUrl.value; final playerType = main.getLinkPlayerType(url); @@ -230,8 +231,13 @@ class Buttons { getEl("#mediatitleblock").style.display = isSingleRawVideo ? "" : "none"; getEl("#subsurlblock").style.display = isSingleRawVideo ? "" : "none"; getEl("#voiceoverblock").style.display = (url.length > 0 && isSingle) ? "" : "none"; - final showCache = isSingle && main.playersCacheSupport.contains(playerType); - getEl("#cache-on-server").parentElement.style.display = showCache ? "" : "none"; + + final isExternal = main.isExternalVideoUrl(url); + final showCache = isSingle && isExternal + && main.playersCacheSupport.contains(playerType); + checkboxCache.parentElement.style.display = showCache ? "" : "none"; + checkboxCache.checked = settings.checkedCache.contains(playerType); + final panel = getEl("#addfromurl"); final oldH = panel.style.height; // save for animation panel.style.height = ""; // to calculate height from content @@ -241,6 +247,16 @@ class Buttons { } mediaUrl.onfocus = mediaUrl.oninput; + checkboxCache.addEventListener("change", () -> { + final url = mediaUrl.value; + final playerType = main.getLinkPlayerType(url); + final checked = checkboxCache.checked; + + settings.checkedCache.remove(playerType); + if (checked) settings.checkedCache.push(playerType); + Settings.write(settings); + }); + getEl("#insert_template").onclick = e -> { mediaUrl.value = main.getTemplateUrl(); mediaUrl.focus(); @@ -518,7 +534,6 @@ class Buttons { }); final checkboxes:Array<InputElement> = [ getEl("#add-temp"), - getEl("#cache-on-server"), ]; for (checkbox in checkboxes) { checkbox.addEventListener("change", () -> { diff --git a/src/client/ClientSettings.hx b/src/client/ClientSettings.hx index cb5f99f..1bca427 100644 --- a/src/client/ClientSettings.hx +++ b/src/client/ClientSettings.hx @@ -1,19 +1,20 @@ package client; +import Types.PlayerType; + typedef ClientSettings = { - version:Int, - uuid:Null<String>, - name:String, - hash:String, - isExtendedPlayer:Bool, - playerSize:Float, - chatSize:Float, - synchThreshold:Int, - isSwapped:Bool, - isUserListHidden:Bool, - latestLinks:Array<String>, - latestSubs:Array<String>, - hotkeysEnabled:Bool, - showHintList:Bool, - checkboxes:Array<{id:String, checked:Null<Bool>}>, + var version:Int; + var uuid:Null<String>; + var name:String; + var hash:String; + var chatSize:Float; + var synchThreshold:Int; + var isSwapped:Bool; + var isUserListHidden:Bool; + var latestLinks:Array<String>; + var latestSubs:Array<String>; + var hotkeysEnabled:Bool; + var showHintList:Bool; + var checkboxes:Array<{id:String, checked:Null<Bool>}>; + var checkedCache:Array<PlayerType>; } diff --git a/src/client/Main.hx b/src/client/Main.hx index e7f8f30..abc39f6 100644 --- a/src/client/Main.hx +++ b/src/client/Main.hx @@ -27,7 +27,7 @@ import js.html.WebSocket; class Main { public static var instance(default, null):Main; - static inline var SETTINGS_VERSION = 5; + static inline var SETTINGS_VERSION = 6; static inline var MAX_CHAT_MESSAGES = 200; public final settings:ClientSettings; @@ -81,8 +81,6 @@ class Main { uuid: null, name: "", hash: "", - isExtendedPlayer: false, - playerSize: 1, chatSize: 300, synchThreshold: 2, isSwapped: false, @@ -92,6 +90,7 @@ class Main { hotkeysEnabled: true, showHintList: true, checkboxes: [], + checkedCache: [], } Settings.init(defaults, settingsPatcher); settings = Settings.read(); @@ -139,6 +138,16 @@ class Main { case 4: final data:ClientSettings = data; data.checkboxes = []; + case 5: + final data:ClientSettings = data; + data.checkedCache = []; + Reflect.deleteField(data, "playerSize"); + Reflect.deleteField(data, "isExtendedPlayer"); + final oldCheck = data.checkboxes.find(item -> item.id == "cache-on-server"); + if (oldCheck != null) { + data.checkboxes.remove(oldCheck); + data.checkedCache.push(YoutubeType); + } case SETTINGS_VERSION, _: throw 'skipped version $version'; } @@ -307,6 +316,14 @@ class Main { return player.isSingleVideoUrl(url); } + public function isExternalVideoUrl(url:String):Bool { + url = url.ltrim(); + if (url.startsWith("/")) return false; + final host = Browser.location.hostname; + if (url.contains(host)) return false; + return true; + } + public function sortItemsForQueueNext<T>(items:Array<T>):Void { if (items.length == 0) return; // except first item when list empty diff --git a/src/server/ConsoleInput.hx b/src/server/ConsoleInput.hx index cf88df6..e0712f5 100644 --- a/src/server/ConsoleInput.hx +++ b/src/server/ConsoleInput.hx @@ -107,7 +107,7 @@ class ConsoleInput { case AddAdmin: final name = args[0]; final password = args[1]; - if (main.badNickName(name)) { + if (main.isBadClientName(name)) { final error = Lang.get("usernameError") .replace("$MAX", '${main.config.maxLoginLength}'); trace(error); diff --git a/src/server/HttpServer.hx b/src/server/HttpServer.hx index 4734815..dd8c178 100644 --- a/src/server/HttpServer.hx +++ b/src/server/HttpServer.hx @@ -12,6 +12,7 @@ import js.node.http.ClientRequest; import js.node.http.IncomingMessage; import js.node.http.ServerResponse; import js.node.url.URL; +import server.cache.Cache; import sys.FileSystem; @:structInit diff --git a/src/server/Main.hx b/src/server/Main.hx index 86e619e..d935157 100644 --- a/src/server/Main.hx +++ b/src/server/Main.hx @@ -22,6 +22,7 @@ import js.npm.ws.Server as WSServer; import js.npm.ws.WebSocket; import json2object.ErrorUtils; import json2object.JsonParser; +import server.cache.Cache; import sys.FileSystem; import sys.io.File; @@ -48,7 +49,7 @@ class Main { var wss:WSServer; final localIp:String; var globalIp:String; - final playersCacheSupport:Array<PlayerType> = []; + final playersCacheSupport:Array<PlayerType> = [RawType]; var port:Int; final userList:UserList; @@ -577,7 +578,7 @@ class Main { case Login: final name = data.login.clientName.trim(); final lcName = name.toLowerCase(); - if (badNickName(lcName)) { + if (isBadClientName(lcName)) { serverMessage(client, "usernameError"); send(client, {type: LoginError}); return; @@ -686,6 +687,11 @@ class Main { addVideo(); } else { switch item.playerType { + case RawType: + cache.cacheRawVideo(client, item.url, (name) -> { + item = item.withUrl(cache.getFileUrl(name)); + addVideo(); + }); case YoutubeType: cache.cacheYoutubeVideo(client, item.url, (name) -> { item = item.withUrl(cache.getFileUrl(name)); @@ -1065,7 +1071,7 @@ class Main { final matchHtmlChars = ~/[&^<>'"]/; final matchGuestName = ~/guest [0-9]+/; - public function badNickName(name:String):Bool { + public function isBadClientName(name:String):Bool { if (name.length > config.maxLoginLength) return true; if (name.length == 0) return true; if (matchHtmlChars.match(name)) return true; @@ -1147,4 +1153,8 @@ class Main { } return false; } + + public function hasPlaylistUrl(url:String):Bool { + return videoList.exists(item -> item.url == url); + } } diff --git a/src/server/cache/Cache.hx b/src/server/cache/Cache.hx new file mode 100644 index 0000000..f71b465 --- /dev/null +++ b/src/server/cache/Cache.hx @@ -0,0 +1,171 @@ +package server.cache; + +import haxe.io.Path; +import js.node.Fs.Fs; +import sys.FileSystem; + +class Cache { + public final notEnoughSpaceErrorText = "Error: Not enough free space on server or file size is out of cache storage limit."; + + public final isYtReady = false; + + /** In bytes **/ + public var storageLimit(default, null) = 3 * 1024 * 1024 * 1024; + + final main:Main; + + public final cacheDir:String; + public final freeSpaceBlock = 10 * 1024 * 1024; // 10MB + + final cachedFiles:Array<String> = []; + var youtubeCache:YoutubeCache; + var rawCache:RawCache; + + public function new(main:Main, cacheDir:String) { + this.main = main; + this.cacheDir = cacheDir; + Utils.ensureDir(cacheDir); + youtubeCache = new YoutubeCache(main, this); + rawCache = new RawCache(main, this); + isYtReady = youtubeCache.checkYtDeps(); + if (isYtReady) youtubeCache.cleanYtInputFiles(); + } + + public function getCachedFiles():Array<String> { + return cachedFiles; + } + + public function setCachedFiles(names:Array<String>) { + cachedFiles.resize(0); + for (name in names) cachedFiles.push(name); + + final names = FileSystem.readDirectory(cacheDir); + for (name in names) { + if (name.startsWith(".")) continue; + if (FileSystem.isDirectory('$cacheDir/$name')) continue; + if (cachedFiles.contains(name)) continue; + trace('Remove non-tracked cache $name'); + remove(name); + } + } + + public function log(client:Client, msg:String):Void { + main.serverMessage(client, msg); + trace(msg); + } + + public function cacheYoutubeVideo(client:Client, url:String, callback:(name:String) -> Void) { + youtubeCache.cacheYoutubeVideo(client, url, callback); + } + + public function cacheRawVideo(client:Client, url:String, callback:(name:String) -> Void) { + rawCache.cacheRawVideo(client, url, callback); + } + + public function setStorageLimit(bytes:Int) { + storageLimit = cast bytes; + storageLimit = storageLimit.limitMin(0); + getFreeDiskSpace(availSpace -> { + final availSpace = (availSpace - freeSpaceBlock).limitMin(0); + removeOlderCache(); + final freeSpace = getFreeSpace(); + if (availSpace < freeSpace) { + // shrink limit lower than disk space + storageLimit += availSpace - freeSpace; + storageLimit = storageLimit.limitMin(0); + removeOlderCache(); + } + }); + } + + public function getFreeDiskSpace(callback:(availSpace:Int) -> Void):Void { + final statfs = (Fs : Dynamic).statfs ?? { + trace("Warning: no fs.statfs support in current nodejs version (needs v18+)"); + callback(storageLimit); + return; + } + statfs("/", (err, stats) -> { + if (err != null) { + trace(err); + callback(storageLimit); + return; + } + callback(stats.bsize * stats.bavail); + }); + } + + public function add(name:String) { + if (!cachedFiles.contains(name)) { + cachedFiles.unshift(name); + } + } + + public function remove(name:String):Void { + cachedFiles.remove(name); + removeFile(name); + } + + public function exists(name:String):Bool { + return cachedFiles.contains(name) && isFileExists(name); + } + + /** Returns `true` if there is enough space to save `addFileSize` bytes. **/ + public function removeOlderCache(addFileSize = 0):Bool { + var space = getUsedSpace(addFileSize); + for (name in cachedFiles.reversed()) { + if (space <= storageLimit) break; + // do not remove cached items that are in playlist + if (main.hasPlaylistUrl(getFileUrl(name))) continue; + remove(name); + space = getUsedSpace(addFileSize); + } + return space < storageLimit; + } + + function removeFile(name:String):Void { + final path = getFilePath(name); + if (FileSystem.exists(path)) FileSystem.deleteFile(path); + } + + public function getFreeFileName(fullName = "video.mp4"):String { + final baseName = Path.withoutDirectory(Path.withoutExtension(fullName)); + final ext = Path.extension(fullName); + var i = 1; + while (true) { + final n = i == 1 ? "" : '$i'; + final name = '$baseName$n.$ext'; + if (!isFileExists(name)) return name; + i++; + } + } + + public function getFilePath(name:String):String { + return '$cacheDir/$name'; + } + + public function getFileUrl(name:String):String { + final folder = Path.withoutDirectory(cacheDir); + return '/$folder/$name'; + } + + public function isFileExists(name:String):Bool { + return FileSystem.exists(getFilePath(name)); + } + + public function getFreeSpace():Int { + return storageLimit - getUsedSpace(); + } + + public function getUsedSpace(addFileSize = 0):Int { + var total = addFileSize.limitMin(0); + for (name in cachedFiles.reversed()) { + final path = getFilePath(name); + if (!FileSystem.exists(path)) { + cachedFiles.remove(name); + continue; + } + total += FileSystem.stat(path).size; + } + return total; + } +} diff --git a/src/server/cache/RawCache.hx b/src/server/cache/RawCache.hx new file mode 100644 index 0000000..ed8679c --- /dev/null +++ b/src/server/cache/RawCache.hx @@ -0,0 +1,469 @@ +package server.cache; + +import js.node.Buffer; +import js.node.ChildProcess; +import js.node.Fs.Fs; +import js.node.Http; +import js.node.Https; +import js.node.http.ClientRequest; +import js.node.http.IncomingMessage; +import sys.FileSystem; +import sys.io.File; + +typedef Segment = { + i:Int, + url:String, + started:Bool, + completed:Bool, + name:String +} + +class RawCache { + final main:Main; + final cache:Cache; + + public function new(main:Main, cache:Cache):Void { + this.main = main; + this.cache = cache; + } + + public function cacheRawVideo(client:Client, url:String, callback:(name:String) -> Void) { + final isM3U8 = url.contains(".m3u8"); + final ext = isM3U8 ? "m3u8" : "mp4"; + + final matchName = ~/^([^:.]+)\.(.+)/; + final decodedUrl = try url.urlDecode() catch (e) url; + final lastPart = decodedUrl.substr(decodedUrl.lastIndexOf("/") + 1); + var outName = matchName.match(lastPart) ? matchName.matched(1) + + '.$ext' : 'video.$ext'; + outName = cache.getFreeFileName(outName); + + if (cache.exists(outName)) { + callback(outName); + return; + } + + trace('Caching $url to $outName...'); + main.send(client, { + type: Progress, + progress: { + type: Caching, + ratio: 0, + data: outName + } + }); + + if (isM3U8) { + handleM3u8(client, url, outName, callback); + } else { + handleMp4(client, url, outName, callback); + } + } + + function handleMp4(client:Client, url:String, outName:String, callback:(name:String) -> Void) { + downloadFile(client, url, outName, (downloaded, total) -> { + main.send(client, { + type: Progress, + progress: { + type: Downloading, + ratio: (downloaded / total).clamp(0, 1) + } + }); + }, () -> { + cache.add(outName); + callback(outName); + }, (err) -> { + log(client, 'Mp4 download failed: $err'); + cancelProgress(client); + }); + } + + function handleM3u8(client:Client, url:String, outName:String, callback:(name:String) -> Void):Void { + final useProxy = true; + downloadM3u8Playlist(client, url, useProxy, (playlist, totalSize, segments) -> { + // only playlist file donwloaded + if (useProxy) totalSize = playlist.length; + + if (!cache.removeOlderCache(totalSize + cache.freeSpaceBlock)) { + log(client, cache.notEnoughSpaceErrorText); + cancelProgress(client); + return; + } + + if (useProxy) { + main.send(client, { + type: Progress, + progress: { + type: Caching, + ratio: 1, + data: outName + } + }); + File.saveContent('${cache.cacheDir}/$outName', playlist); + cache.add(outName); + callback(outName); + return; + } + + var activeDownloads = 0; + final maxParallelDownloads = 10; + var downloaded = 0; + + function downloadNextBatch():Void { + for (segment in segments) { + if (activeDownloads >= maxParallelDownloads) break; + if (segment.started) continue; + segment.started = true; + activeDownloads++; + trace("download segment", segment.i); + + downloadFile(client, segment.url, segment.name, + (downloadedBytes, totalBytes) -> {}, + + () -> { + activeDownloads--; + segment.completed = true; + downloaded++; + + final progress = downloaded / segments.length; + main.send(client, { + type: Progress, + progress: { + type: Downloading, + ratio: progress.clamp(0, 1) + } + }); + + if (downloaded == segments.length) { + trace('All ${downloaded}/${segments.length} segments downloaded'); + + File.saveContent('${cache.cacheDir}/$outName', playlist); + cache.add(outName); + callback(outName); + // buildTsFiles( + // segments.map(item -> item.name), + // outName, + // client, + // callback + // ); + } else { + downloadNextBatch(); + } + }, + + (err) -> { + activeDownloads--; + downloaded++; + log(client, 'TS segment ${segment.i} download failed: $err'); + cancelProgress(client); + cleanupFiles(segments.map(item -> item.name)); + } + ); + } + } + + // Start the initial batch of downloads + downloadNextBatch(); + }, (err) -> { + log(client, 'M3U8 processing failed: $err'); + cancelProgress(client); + }); + } + + function request(url:String, ?options:Null<HttpsRequestOptions>, ?callback:Null<IncomingMessage-> + Void>):ClientRequest { + final httpsOptions:HttpsRequestOptions = options ?? {}; + // Allow self-signed certificates + httpsOptions.rejectUnauthorized = false; + httpsOptions.headers ??= {}; + + if (url.startsWith("https:")) { + return Https.request(url, httpsOptions, callback); + } else { + return Http.request(url, httpsOptions, callback); + } + } + + function downloadM3u8Playlist( + client:Client, + url:String, + useProxy:Bool, + onSuccess:(playlist:String, totalSize:Int, segments:Array<Segment>) -> Void, + onError:(err:String) -> Void + ) { + final options:HttpsRequestOptions = { + headers: { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36", + "Accept": "*/*", + } + }; + + final req = request(url, options, (res:IncomingMessage) -> { + if (res.statusCode >= 300 && res.statusCode < 400) { + final redirectUrl = res.headers.get("location"); + if (redirectUrl != null) { + downloadM3u8Playlist(client, redirectUrl, useProxy, onSuccess, onError); + return; + } + } + + final body:Array<Any> = []; + res.on("data", chunk -> body.push(chunk)); + res.on("end", () -> { + try { + final buffer = Buffer.concat(body); + final content = buffer.toString(); + if (!~/^#EXTM3U/.match(content)) { + onError("Invalid M3U8 playlist"); + return; + } + + final baseUrl = url.substring(0, url.lastIndexOf("/") + 1); + final segments:Array<Segment> = []; + + final lines = content.split("\n"); + for (lineI => line in lines) { + final line = line.trim(); + if (line.length == 0) continue; + if (line.startsWith("#")) continue; + final segmentUrl = !line.contains("://") ? baseUrl + line : line; + final i = segments.length; + final segment:Segment = { + i: i, + url: segmentUrl, + started: false, + completed: false, + name: 'segment$i.ts', + } + segments.push(segment); + lines[lineI] = './${segment.name}'; + if (useProxy) { + lines[lineI] = '/proxy?url=$segmentUrl'; + } + } + + // Head request can return full stream size, so lets do loose assumption + final req = request(segments[0].url, {method: Get}, (res:IncomingMessage) -> { + final contentLength = Std.parseInt(res.headers["content-length"]) ?? 0; + final totalSize = contentLength * (segments.length + 1); + if (totalSize == 0) { + onError("Failed to get segment sizes: no content-length"); + return; + } + onSuccess(lines.join("\n"), totalSize, segments); + }); + req.on("error", (err) -> { + onError("Request error: failed to get segment sizes"); + }); + req.end(); + } catch (e) { + onError('Playlist processing error: $e'); + } + }); + }); + + req.on("error", onError); + req.end(); + } + + function downloadFile( + client:Client, url:String, fileName:String, + onProgress:(downloaded:Int, total:Int) -> Void, + onComplete:() -> Void, + onError:(err:String) -> Void + ):Void { + final outPath = '${cache.cacheDir}/$fileName'; + final file = Fs.createWriteStream(outPath); + final options:HttpsRequestOptions = { + method: Get, + headers: { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537." + + Std.random(100), + "Accept": "*/*", + } + }; + final req = request(url, options, (res:IncomingMessage) -> { + final total = Std.parseInt(res.headers["content-length"]) ?? 0; + var downloaded = 0; + + // Handle response data chunks + res.on("data", (chunk) -> { + downloaded += chunk.length; + onProgress(downloaded, total); + + // Handle backpressure + if (!file.write(chunk)) { + res.pause(); + file.once("drain", () -> res.resume()); + } + }); + + // Handle response completion + res.on("end", () -> file.end()); + + // Handle response errors + res.on("error", (err) -> { + file.destroy(); + onError('Response error: $err'); + }); + }); + + // Handle file write completion + file.on("finish", onComplete); + + // Handle file system errors + file.on("error", (err) -> { + req.destroy(); + onError('File error: $err'); + }); + + // Handle request errors + req.on("error", (err) -> { + file.destroy(); + onError('Request failed: $err'); + }); + + req.end(); + } + + function buildTsFiles(tempFiles:Array<String>, outName:String, client:Client, callback:String->Void) { + final missingFiles = tempFiles.filter(f -> + !FileSystem.exists('${cache.cacheDir}/$f')); + if (missingFiles.length > 0) { + log(client, 'Concatenation failed: ${missingFiles.length} segments are missing'); + main.send(client, { + type: Progress, + progress: { + type: Canceled, + ratio: 1 + } + }); + cleanupFiles(tempFiles); + return; + } + + // Create concat file with absolute paths and proper escaping + final concatFile = 'concat_list.txt'; + final concatContent = tempFiles.map(f -> { + final path = './$f'; + return 'file \'${path}\''; + }).join("\n"); + + File.saveContent('${cache.cacheDir}/$concatFile', concatContent); + + // Prepare FFmpeg args with proper bitstream filters for TS files + final args = [ + "-y", // Overwrite output without asking + "-f", "concat", // Use concat format + "-safe", "0", // Allow absolute paths + "-i", concatFile, // Input file list + "-c", "copy", // Copy streams without re-encoding + "-bsf:a", "aac_adtstoasc", // Fix AAC audio streams from TS files + "-movflags", "+faststart", // Optimize for web streaming + outName // Output filename + ]; + + trace('Executing FFmpeg with args: ${args.join(" ")}'); + + // Create process with proper error capturing + final process = ChildProcess.spawn("ffmpeg", args, { + cwd: cache.cacheDir, + // stderr: "pipe" // Capture stderr for error reporting + }); + + final errorOutput:Array<Buffer> = []; + process.stderr.on("data", (data) -> errorOutput.push(data)); + + // Set a reasonable timeout + final timeout = 5 * 60 * 1000; // 5 minutes + final timeoutId = js.Node.setTimeout(() -> { + process.kill(); + log(client, 'FFmpeg process timed out after ${timeout / 1000} seconds'); + main.send(client, { + type: Progress, + progress: { + type: Canceled, + ratio: 1 + } + }); + cleanupFiles(tempFiles.concat([concatFile])); + }, timeout); + + process.on("close", (code:Int) -> { + js.Node.clearTimeout(timeoutId); + + if (code != 0) { + final errorMsg = Buffer.concat(errorOutput).toString(); + log(client, 'FFmpeg concatenation failed with code $code'); + trace('FFmpeg error output: $errorMsg'); + + // Log detailed error to admins + final admins = main.clients.filter(client -> client.isAdmin); + for (admin in admins) { + log(admin, 'FFmpeg error: $errorMsg'); + } + + main.send(client, { + type: Progress, + progress: { + type: Canceled, + ratio: 1 + } + }); + } else { + // Verify the output file exists and has content + if (FileSystem.exists('${cache.cacheDir}/$outName') + && FileSystem.stat('${cache.cacheDir}/$outName').size > 0) { + cache.add(outName); + callback(outName); + } else { + log(client, 'FFmpeg process completed but output file is missing or empty'); + main.send(client, { + type: Progress, + progress: { + type: Canceled, + ratio: 1 + } + }); + } + } + + // Clean up temporary files after everything is done + cleanupFiles(tempFiles.concat([concatFile])); + }); + + // Handle process errors (like if FFmpeg isn't found) + process.on("error", (err) -> { + js.Node.clearTimeout(timeoutId); + log(client, 'Failed to start FFmpeg: $err'); + main.send(client, { + type: Progress, + progress: { + type: Canceled, + ratio: 1 + } + }); + cleanupFiles(tempFiles.concat([concatFile])); + }); + } + + function cleanupFiles(files:Array<String>):Void { + for (file in files) { + if (FileSystem.exists(file)) FileSystem.deleteFile(file); + } + } + + function log(client:Client, msg:String):Void { + cache.log(client, msg); + } + + function cancelProgress(client:Client):Void { + main.send(client, { + type: Progress, + progress: { + type: Canceled, + ratio: 0 + } + }); + } +} diff --git a/src/server/Cache.hx b/src/server/cache/YoutubeCache.hx index ef74517..c0a5c4c 100644 --- a/src/server/Cache.hx +++ b/src/server/cache/YoutubeCache.hx @@ -1,7 +1,6 @@ -package server; +package server.cache; import haxe.Json; -import haxe.io.Path; import js.lib.Promise; import js.node.Buffer; import js.node.ChildProcess; @@ -11,28 +10,16 @@ import sys.FileSystem; import sys.io.File; import utils.YoutubeUtils; -class Cache { - public final notEnoughSpaceErrorText = "Error: Not enough free space on server or file size is out of cache storage limit."; - - public final isYtReady = false; - - /** In bytes **/ - public var storageLimit(default, null) = 3 * 1024 * 1024 * 1024; - +class YoutubeCache { final main:Main; - final cacheDir:String; - final cachedFiles:Array<String> = []; - final freeSpaceBlock = 10 * 1024 * 1024; // 10MB + final cache:Cache; - public function new(main:Main, cacheDir:String) { + public function new(main:Main, cache:Cache):Void { this.main = main; - this.cacheDir = cacheDir; - Utils.ensureDir(cacheDir); - isYtReady = checkYtDeps(); - if (isYtReady) cleanYtInputFiles(); + this.cache = cache; } - function checkYtDeps():Bool { + public function checkYtDeps():Bool { final ytdl = try { untyped require("@distube/ytdl-core"); } catch (e) { @@ -46,39 +33,16 @@ class Cache { } } - function cleanYtInputFiles():Void { - final names = FileSystem.readDirectory(cacheDir); + public function cleanYtInputFiles():Void { + final names = FileSystem.readDirectory(cache.cacheDir); for (name in names) { if (!name.startsWith("__tmp")) continue; - remove(name); - } - } - - public function getCachedFiles():Array<String> { - return cachedFiles; - } - - public function setCachedFiles(names:Array<String>) { - cachedFiles.resize(0); - for (name in names) cachedFiles.push(name); - - final names = FileSystem.readDirectory(cacheDir); - for (name in names) { - if (name.startsWith(".")) continue; - if (FileSystem.isDirectory('$cacheDir/$name')) continue; - if (cachedFiles.contains(name)) continue; - trace('Remove non-tracked cache $name'); - remove(name); + cache.remove(name); } } - function log(client:Client, msg:String):Void { - main.serverMessage(client, msg); - trace(msg); - } - public function cacheYoutubeVideo(client:Client, url:String, callback:(name:String) -> Void) { - if (!isYtReady) { + if (!cache.isYtReady) { trace("Do `npm i @distube/ytdl-core@latest` to use cache feature (you also need to install `ffmpeg` to build mp4 from downloaded audio/video tracks)."); return; } @@ -88,36 +52,27 @@ class Cache { return; } final outName = videoId + ".mp4"; - if (cachedFiles.contains(outName) && isFileExists(outName)) { + if (cache.exists(outName)) { callback(outName); return; } final inVideoName = '__tmp-video-$videoId'; final inAudioName = '__tmp-audio-$videoId'; inline function removeInputFiles():Void { - remove(inVideoName); - remove(inAudioName); - } - inline function cancelProgress():Void { - main.send(client, { - type: Progress, - progress: { - type: Canceled, - ratio: 1 - } - }); + cache.remove(inVideoName); + cache.remove(inAudioName); } inline function checkEnoughSpace(contentLength:Int):Bool { - final hasSpace = removeOlderCache(contentLength + freeSpaceBlock); + final hasSpace = cache.removeOlderCache(contentLength + cache.freeSpaceBlock); if (!hasSpace) { removeInputFiles(); - cancelProgress(); - log(client, notEnoughSpaceErrorText); + cancelProgress(client); + log(client, cache.notEnoughSpaceErrorText); } return hasSpace; } - if (isFileExists(inVideoName)) { + if (cache.isFileExists(inVideoName)) { log(client, 'Caching $outName already in progress'); return; } @@ -162,7 +117,8 @@ class Cache { return videoSize + audioSize; } // check if we have space for formats and video build - final hasSpace = removeOlderCache(getTotalFormatsSize() * 2 + freeSpaceBlock); + final hasSpace = cache.removeOlderCache(getTotalFormatsSize() * 2 + + cache.freeSpaceBlock); if (!hasSpace) { // try fallback to worse video quality videoFormat = getBestYoutubeVideoFormat(info.formats, videoFormat.qualityLabel); @@ -173,22 +129,22 @@ class Cache { format: videoFormat, agent: agent, }); - dlVideo.pipe(Fs.createWriteStream('$cacheDir/$inVideoName')); + dlVideo.pipe(Fs.createWriteStream('${cache.cacheDir}/$inVideoName')); dlVideo.on("error", err -> { log(client, "Error during video download: " + err); removeInputFiles(); - cancelProgress(); + cancelProgress(client); }); final dlAudio:Readable<Dynamic> = ytdl(url, { format: audioFormat, agent: agent, }); - dlAudio.pipe(Fs.createWriteStream('$cacheDir/$inAudioName')); + dlAudio.pipe(Fs.createWriteStream('${cache.cacheDir}/$inAudioName')); dlAudio.on("error", err -> { log(client, "Error during audio download: " + err); removeInputFiles(); - cancelProgress(); + cancelProgress(client); }); var count = 0; @@ -196,20 +152,20 @@ class Cache { count++; trace('$type track downloaded ($count/2)'); if (count < 2) return; - if (!isFileExists(inVideoName) || !isFileExists(inAudioName)) { + if (!cache.isFileExists(inVideoName) || !cache.isFileExists(inAudioName)) { log(client, "Input files not found for making final video"); removeInputFiles(); - cancelProgress(); + cancelProgress(client); return; } - var size = FileSystem.stat('$cacheDir/$inVideoName').size; - size += FileSystem.stat('$cacheDir/$inAudioName').size; + var size = FileSystem.stat('${cache.cacheDir}/$inVideoName').size; + size += FileSystem.stat('${cache.cacheDir}/$inAudioName').size; // clean some space for full mp4 if (!checkEnoughSpace(size)) return; final args = '-y -i ./$inVideoName -i ./$inAudioName -c copy -map 0:v -map 1:a ./$outName'.split(" "); final process = ChildProcess.spawn("ffmpeg", args, { - cwd: cacheDir, + cwd: cache.cacheDir, // stdio: "ignore" }); final outputData:Array<Buffer> = []; @@ -217,7 +173,7 @@ class Cache { process.on("close", (code:Int) -> { removeInputFiles(); if (code != 0) { - cancelProgress(); + cancelProgress(client); final errCodeMsg = 'Error: ffmpeg closed with code $code'; final admins = main.clients.filter(client -> client.isAdmin); for (client in admins) { @@ -227,7 +183,7 @@ class Cache { if (!admins.contains(client)) log(client, errCodeMsg); return; } - add(outName); + cache.add(outName); callback(outName); }); @@ -246,112 +202,11 @@ class Cache { }); }).catchError(err -> { removeInputFiles(); - cancelProgress(); + cancelProgress(client); log(client, "" + err); }); } - public function setStorageLimit(bytes:Int) { - storageLimit = cast bytes; - storageLimit = storageLimit.limitMin(0); - getFreeDiskSpace(availSpace -> { - final availSpace = (availSpace - freeSpaceBlock).limitMin(0); - removeOlderCache(); - final freeSpace = getFreeSpace(); - if (availSpace < freeSpace) { - // shrink limit lower than disk space - storageLimit += availSpace - freeSpace; - storageLimit = storageLimit.limitMin(0); - removeOlderCache(); - } - }); - } - - public function getFreeDiskSpace(callback:(availSpace:Int) -> Void):Void { - final statfs = (Fs : Dynamic).statfs ?? { - trace("Warning: no fs.statfs support in current nodejs version (needs v18+)"); - callback(storageLimit); - return; - } - statfs("/", (err, stats) -> { - if (err != null) { - trace(err); - callback(storageLimit); - return; - } - callback(stats.bsize * stats.bavail); - }); - } - - public function add(name:String) { - if (!cachedFiles.contains(name)) { - cachedFiles.unshift(name); - } - } - - public function remove(name:String):Void { - cachedFiles.remove(name); - removeFile(name); - } - - /** Returns `true` if there is enough space to save `addFileSize` bytes. **/ - public function removeOlderCache(addFileSize = 0):Bool { - var space = getUsedSpace(addFileSize); - while (space > storageLimit) { - final name = cachedFiles.pop() ?? break; - removeFile(name); - space = getUsedSpace(addFileSize); - } - return space < storageLimit; - } - - function removeFile(name:String):Void { - final path = getFilePath(name); - if (FileSystem.exists(path)) FileSystem.deleteFile(path); - } - - public function getFreeFileName(fullName = "video.mp4"):String { - final baseName = Path.withoutDirectory(Path.withoutExtension(fullName)); - final ext = Path.extension(fullName); - var i = 1; - while (true) { - final n = i == 1 ? "" : '$i'; - final name = '$baseName$n.$ext'; - if (!isFileExists(name)) return name; - i++; - } - } - - public function getFilePath(name:String):String { - return '$cacheDir/$name'; - } - - public function getFileUrl(name:String):String { - final folder = Path.withoutDirectory(cacheDir); - return '/$folder/$name'; - } - - public function isFileExists(name:String):Bool { - return FileSystem.exists(getFilePath(name)); - } - - public function getFreeSpace():Int { - return storageLimit - getUsedSpace(); - } - - public function getUsedSpace(addFileSize = 0):Int { - var total = addFileSize.limitMin(0); - for (name in cachedFiles.reversed()) { - final path = getFilePath(name); - if (!FileSystem.exists(path)) { - cachedFiles.remove(name); - continue; - } - total += FileSystem.stat(path).size; - } - return total; - } - function getBestYoutubeVideoFormat(formats:Array<YoutubeVideoFormat>, ?ignoreQuality:String):Null<YoutubeVideoFormat> { final qPriority = [1080, 720, 480, 360, 240, 144]; for (q in qPriority) { @@ -364,4 +219,18 @@ class Cache { } return null; } + + function log(client:Client, msg:String):Void { + cache.log(client, msg); + } + + function cancelProgress(client:Client):Void { + main.send(client, { + type: Progress, + progress: { + type: Canceled, + ratio: 0 + } + }); + } } |
