From f874dcd3de368e7e512ab1c0defdd17bc3026ce5 Mon Sep 17 00:00:00 2001 From: RblSb Date: Tue, 25 Mar 2025 03:02:03 +0300 Subject: 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 --- src/VideoList.hx | 11 +- src/client/Buttons.hx | 21 +- src/client/ClientSettings.hx | 31 +-- src/client/Main.hx | 23 +- src/server/Cache.hx | 367 ------------------------------ src/server/ConsoleInput.hx | 2 +- src/server/HttpServer.hx | 1 + src/server/Main.hx | 16 +- src/server/cache/Cache.hx | 171 ++++++++++++++ src/server/cache/RawCache.hx | 469 +++++++++++++++++++++++++++++++++++++++ src/server/cache/YoutubeCache.hx | 236 ++++++++++++++++++++ 11 files changed, 950 insertions(+), 398 deletions(-) delete mode 100644 src/server/Cache.hx create mode 100644 src/server/cache/Cache.hx create mode 100644 src/server/cache/RawCache.hx create mode 100644 src/server/cache/YoutubeCache.hx (limited to 'src') 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 { + 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 = [ 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, - name:String, - hash:String, - isExtendedPlayer:Bool, - playerSize:Float, - chatSize:Float, - synchThreshold:Int, - isSwapped:Bool, - isUserListHidden:Bool, - latestLinks:Array, - latestSubs:Array, - hotkeysEnabled:Bool, - showHintList:Bool, - checkboxes:Array<{id:String, checked:Null}>, + var version:Int; + var uuid:Null; + var name:String; + var hash:String; + var chatSize:Float; + var synchThreshold:Int; + var isSwapped:Bool; + var isUserListHidden:Bool; + var latestLinks:Array; + var latestSubs:Array; + var hotkeysEnabled:Bool; + var showHintList:Bool; + var checkboxes:Array<{id:String, checked:Null}>; + var checkedCache:Array; } 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(items:Array):Void { if (items.length == 0) return; // except first item when list empty diff --git a/src/server/Cache.hx b/src/server/Cache.hx deleted file mode 100644 index ef74517..0000000 --- a/src/server/Cache.hx +++ /dev/null @@ -1,367 +0,0 @@ -package server; - -import haxe.Json; -import haxe.io.Path; -import js.lib.Promise; -import js.node.Buffer; -import js.node.ChildProcess; -import js.node.Fs.Fs; -import js.node.stream.Readable; -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; - - final main:Main; - final cacheDir:String; - final cachedFiles:Array = []; - final freeSpaceBlock = 10 * 1024 * 1024; // 10MB - - public function new(main:Main, cacheDir:String) { - this.main = main; - this.cacheDir = cacheDir; - Utils.ensureDir(cacheDir); - isYtReady = checkYtDeps(); - if (isYtReady) cleanYtInputFiles(); - } - - function checkYtDeps():Bool { - final ytdl = try { - untyped require("@distube/ytdl-core"); - } catch (e) { - return false; - } - try { - ChildProcess.execSync("ffmpeg -version", {stdio: "ignore", timeout: 3000}); - return true; - } catch (e) { - return false; - } - } - - function cleanYtInputFiles():Void { - final names = FileSystem.readDirectory(cacheDir); - for (name in names) { - if (!name.startsWith("__tmp")) continue; - remove(name); - } - } - - public function getCachedFiles():Array { - return cachedFiles; - } - - public function setCachedFiles(names:Array) { - 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); - } - } - - 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) { - 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; - } - final videoId = YoutubeUtils.extractVideoId(url); - if (videoId == "") { - log(client, 'Error: youtube video id not found in url: $url'); - return; - } - final outName = videoId + ".mp4"; - if (cachedFiles.contains(outName) && isFileExists(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 - } - }); - } - inline function checkEnoughSpace(contentLength:Int):Bool { - final hasSpace = removeOlderCache(contentLength + freeSpaceBlock); - if (!hasSpace) { - removeInputFiles(); - cancelProgress(); - log(client, notEnoughSpaceErrorText); - } - return hasSpace; - } - - 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, { - type: Progress, - progress: { - type: Caching, - ratio: 0, - data: outName - } - }); - var agent:Any = null; - final cookiesPath = '${main.userDir}/cookies.json'; - if (FileSystem.exists(cookiesPath)) { - agent = ytdl.createAgent(Json.parse(File.getContent(cookiesPath))); - } - final promise:Promise = ytdl.getInfo(url, { - agent: agent, - }); - promise.then(info -> { - trace('Get info with ${info.formats.length} formats'); - final audioFormat:YoutubeVideoFormat = try { - ytdl.chooseFormat(info.formats.filter(item -> { - return item.audioCodec?.startsWith("mp4a"); - }), {quality: "highestaudio"}); - } catch (e) { - log(client, "Error: audio format not found"); - trace(e); - trace(info.formats.filter(item -> item.hasAudio)); - return; - } - var videoFormat = getBestYoutubeVideoFormat(info.formats) ?? { - log(client, "Error: video format not found"); - trace(info.formats.filter(item -> item.hasVideo)); - return; - } - inline function getTotalFormatsSize():Int { - final videoSize = Std.parseInt(videoFormat.contentLength) ?? 0; - final audioSize = Std.parseInt(audioFormat.contentLength) ?? 0; - return videoSize + audioSize; - } - // check if we have space for formats and video build - final hasSpace = removeOlderCache(getTotalFormatsSize() * 2 + freeSpaceBlock); - if (!hasSpace) { - // try fallback to worse video quality - videoFormat = getBestYoutubeVideoFormat(info.formats, videoFormat.qualityLabel); - if (!checkEnoughSpace(getTotalFormatsSize() * 2)) return; - } - - final dlVideo:Readable = ytdl(url, { - format: videoFormat, - agent: agent, - }); - dlVideo.pipe(Fs.createWriteStream('$cacheDir/$inVideoName')); - dlVideo.on("error", err -> { - log(client, "Error during video download: " + err); - removeInputFiles(); - cancelProgress(); - }); - - final dlAudio:Readable = ytdl(url, { - format: audioFormat, - agent: agent, - }); - dlAudio.pipe(Fs.createWriteStream('$cacheDir/$inAudioName')); - dlAudio.on("error", err -> { - log(client, "Error during audio download: " + err); - removeInputFiles(); - cancelProgress(); - }); - - var count = 0; - function onComplete(type:String):Void { - count++; - trace('$type track downloaded ($count/2)'); - if (count < 2) return; - if (!isFileExists(inVideoName) || !isFileExists(inAudioName)) { - log(client, "Input files not found for making final video"); - removeInputFiles(); - cancelProgress(); - return; - } - var size = FileSystem.stat('$cacheDir/$inVideoName').size; - size += FileSystem.stat('$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, - // stdio: "ignore" - }); - final outputData:Array = []; - process.stderr.on("data", (data) -> outputData.push(data)); - process.on("close", (code:Int) -> { - removeInputFiles(); - if (code != 0) { - cancelProgress(); - final errCodeMsg = 'Error: ffmpeg closed with code $code'; - final admins = main.clients.filter(client -> client.isAdmin); - for (client in admins) { - log(client, Buffer.concat(outputData).toString()); - log(client, errCodeMsg); - } - if (!admins.contains(client)) log(client, errCodeMsg); - return; - } - add(outName); - - callback(outName); - }); - } - dlVideo.on("finish", () -> onComplete("Video")); - dlAudio.on("finish", () -> onComplete("Audio")); - dlVideo.on("progress", (chunkLength:Int, downloaded:Int, contentLength:Int) -> { - final ratio = (downloaded / contentLength).clamp(0, 1); - main.send(client, { - type: Progress, - progress: { - type: Downloading, - ratio: ratio - } - }); - }); - }).catchError(err -> { - removeInputFiles(); - cancelProgress(); - 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, ?ignoreQuality:String):Null { - final qPriority = [1080, 720, 480, 360, 240, 144]; - for (q in qPriority) { - final quality = '${q}p'; - if (quality == ignoreQuality) continue; - for (format in formats) { - if (format.videoCodec == null) continue; - if (format.qualityLabel == quality) return format; - } - } - return null; - } -} 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 = []; + final playersCacheSupport:Array = [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 = []; + 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 { + return cachedFiles; + } + + public function setCachedFiles(names:Array) { + 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, ?callback:Null + 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) -> 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 = []; + 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 = []; + + 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, 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 = []; + 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):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/YoutubeCache.hx b/src/server/cache/YoutubeCache.hx new file mode 100644 index 0000000..c0a5c4c --- /dev/null +++ b/src/server/cache/YoutubeCache.hx @@ -0,0 +1,236 @@ +package server.cache; + +import haxe.Json; +import js.lib.Promise; +import js.node.Buffer; +import js.node.ChildProcess; +import js.node.Fs.Fs; +import js.node.stream.Readable; +import sys.FileSystem; +import sys.io.File; +import utils.YoutubeUtils; + +class YoutubeCache { + final main:Main; + final cache:Cache; + + public function new(main:Main, cache:Cache):Void { + this.main = main; + this.cache = cache; + } + + public function checkYtDeps():Bool { + final ytdl = try { + untyped require("@distube/ytdl-core"); + } catch (e) { + return false; + } + try { + ChildProcess.execSync("ffmpeg -version", {stdio: "ignore", timeout: 3000}); + return true; + } catch (e) { + return false; + } + } + + public function cleanYtInputFiles():Void { + final names = FileSystem.readDirectory(cache.cacheDir); + for (name in names) { + if (!name.startsWith("__tmp")) continue; + cache.remove(name); + } + } + + public function cacheYoutubeVideo(client:Client, url:String, callback:(name:String) -> Void) { + 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; + } + final videoId = YoutubeUtils.extractVideoId(url); + if (videoId == "") { + log(client, 'Error: youtube video id not found in url: $url'); + return; + } + final outName = videoId + ".mp4"; + if (cache.exists(outName)) { + callback(outName); + return; + } + final inVideoName = '__tmp-video-$videoId'; + final inAudioName = '__tmp-audio-$videoId'; + inline function removeInputFiles():Void { + cache.remove(inVideoName); + cache.remove(inAudioName); + } + inline function checkEnoughSpace(contentLength:Int):Bool { + final hasSpace = cache.removeOlderCache(contentLength + cache.freeSpaceBlock); + if (!hasSpace) { + removeInputFiles(); + cancelProgress(client); + log(client, cache.notEnoughSpaceErrorText); + } + return hasSpace; + } + + if (cache.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, { + type: Progress, + progress: { + type: Caching, + ratio: 0, + data: outName + } + }); + var agent:Any = null; + final cookiesPath = '${main.userDir}/cookies.json'; + if (FileSystem.exists(cookiesPath)) { + agent = ytdl.createAgent(Json.parse(File.getContent(cookiesPath))); + } + final promise:Promise = ytdl.getInfo(url, { + agent: agent, + }); + promise.then(info -> { + trace('Get info with ${info.formats.length} formats'); + final audioFormat:YoutubeVideoFormat = try { + ytdl.chooseFormat(info.formats.filter(item -> { + return item.audioCodec?.startsWith("mp4a"); + }), {quality: "highestaudio"}); + } catch (e) { + log(client, "Error: audio format not found"); + trace(e); + trace(info.formats.filter(item -> item.hasAudio)); + return; + } + var videoFormat = getBestYoutubeVideoFormat(info.formats) ?? { + log(client, "Error: video format not found"); + trace(info.formats.filter(item -> item.hasVideo)); + return; + } + inline function getTotalFormatsSize():Int { + final videoSize = Std.parseInt(videoFormat.contentLength) ?? 0; + final audioSize = Std.parseInt(audioFormat.contentLength) ?? 0; + return videoSize + audioSize; + } + // check if we have space for formats and video build + final hasSpace = cache.removeOlderCache(getTotalFormatsSize() * 2 + + cache.freeSpaceBlock); + if (!hasSpace) { + // try fallback to worse video quality + videoFormat = getBestYoutubeVideoFormat(info.formats, videoFormat.qualityLabel); + if (!checkEnoughSpace(getTotalFormatsSize() * 2)) return; + } + + final dlVideo:Readable = ytdl(url, { + format: videoFormat, + agent: agent, + }); + dlVideo.pipe(Fs.createWriteStream('${cache.cacheDir}/$inVideoName')); + dlVideo.on("error", err -> { + log(client, "Error during video download: " + err); + removeInputFiles(); + cancelProgress(client); + }); + + final dlAudio:Readable = ytdl(url, { + format: audioFormat, + agent: agent, + }); + dlAudio.pipe(Fs.createWriteStream('${cache.cacheDir}/$inAudioName')); + dlAudio.on("error", err -> { + log(client, "Error during audio download: " + err); + removeInputFiles(); + cancelProgress(client); + }); + + var count = 0; + function onComplete(type:String):Void { + count++; + trace('$type track downloaded ($count/2)'); + if (count < 2) return; + if (!cache.isFileExists(inVideoName) || !cache.isFileExists(inAudioName)) { + log(client, "Input files not found for making final video"); + removeInputFiles(); + cancelProgress(client); + return; + } + 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: cache.cacheDir, + // stdio: "ignore" + }); + final outputData:Array = []; + process.stderr.on("data", (data) -> outputData.push(data)); + process.on("close", (code:Int) -> { + removeInputFiles(); + if (code != 0) { + cancelProgress(client); + final errCodeMsg = 'Error: ffmpeg closed with code $code'; + final admins = main.clients.filter(client -> client.isAdmin); + for (client in admins) { + log(client, Buffer.concat(outputData).toString()); + log(client, errCodeMsg); + } + if (!admins.contains(client)) log(client, errCodeMsg); + return; + } + cache.add(outName); + + callback(outName); + }); + } + dlVideo.on("finish", () -> onComplete("Video")); + dlAudio.on("finish", () -> onComplete("Audio")); + dlVideo.on("progress", (chunkLength:Int, downloaded:Int, contentLength:Int) -> { + final ratio = (downloaded / contentLength).clamp(0, 1); + main.send(client, { + type: Progress, + progress: { + type: Downloading, + ratio: ratio + } + }); + }); + }).catchError(err -> { + removeInputFiles(); + cancelProgress(client); + log(client, "" + err); + }); + } + + function getBestYoutubeVideoFormat(formats:Array, ?ignoreQuality:String):Null { + final qPriority = [1080, 720, 480, 360, 240, 144]; + for (q in qPriority) { + final quality = '${q}p'; + if (quality == ignoreQuality) continue; + for (format in formats) { + if (format.videoCodec == null) continue; + if (format.qualityLabel == quality) return format; + } + } + 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 + } + }); + } +} -- cgit v1.2.3