aboutsummaryrefslogtreecommitdiffstats
path: root/src/server/cache/YtdlpCache.hx
diff options
context:
space:
mode:
Diffstat (limited to 'src/server/cache/YtdlpCache.hx')
-rw-r--r--src/server/cache/YtdlpCache.hx227
1 files changed, 227 insertions, 0 deletions
diff --git a/src/server/cache/YtdlpCache.hx b/src/server/cache/YtdlpCache.hx
new file mode 100644
index 0000000..31df7b2
--- /dev/null
+++ b/src/server/cache/YtdlpCache.hx
@@ -0,0 +1,227 @@
+package server.cache;
+
+import haxe.io.Path;
+import js.lib.Promise;
+import js.node.ChildProcess;
+import js.node.child_process.ChildProcess as ChildProcessObject;
+import sys.FileSystem;
+import ytdlp_nodejs.ArgsOptions;
+import ytdlp_nodejs.VideoInfo;
+import ytdlp_nodejs.VideoProgress;
+import ytdlp_nodejs.YtDlp;
+
+typedef YtdlpHandlers = {
+ final onProgress:(p:VideoProgress) -> Void;
+ final onComplete:() -> Void;
+ final onError:(err:String) -> Void;
+}
+
+class YtdlpCache {
+ final main:Main;
+ final cache:Cache;
+ var ytDlp:Null<YtDlp>;
+
+ public function new(main:Main, cache:Cache):Void {
+ this.main = main;
+ this.cache = cache;
+ }
+
+ public function checkYtDeps():Bool {
+ try {
+ ChildProcess.execSync("ffmpeg -version", {stdio: "ignore", timeout: 5000});
+ ytDlp = js.Syntax.code("new (require('ytdlp-nodejs')).YtDlp()");
+ return true;
+ } catch (e) {
+ return false;
+ }
+ }
+
+ public function checkUpdate():Void {
+ ytDlp.execAsync("", {
+ updateTo: main.config.ytDlp.channel,
+ onData: d -> {
+ trace(d);
+ }
+ }).catchError(e -> {
+ trace(e);
+ });
+ }
+
+ public function cleanInputFiles(prefix = "__tmp"):Void {
+ final names = FileSystem.readDirectory(cache.cacheDir);
+ for (name in names) {
+ if (!name.startsWith(prefix)) continue;
+ cache.remove(name);
+ }
+ }
+
+ public function getInfoAsync(url:String, useCookies = false):Promise<VideoInfo> {
+ return cast ytDlp.getInfoAsync(url, cast {
+ cookies: useCookies ? getCookiesPathOrNull() : null,
+ additionalOptions: ytExtraOptions(),
+ });
+ }
+
+ public function getCookiesPathOrNull():Null<String> {
+ final cookiesPath = '${main.userDir}/cookies.txt';
+ return FileSystem.exists(cookiesPath) ? cookiesPath : null;
+ }
+
+ public function ytExtraOptions():Array<String> {
+ return ["--no-js-runtimes", "--js-runtimes", main.config.ytDlp.jsRuntime];
+ }
+
+ /**
+ Runs `yt-dlp <url> --ies default,-generic` to check if a site-specific
+ extractor handles the url. Returns the process so it can be killed on cancel.
+ **/
+ public function checkUrlSupported(url:String, callback:(supported:Bool) -> Void):ChildProcessObject {
+ final process:ChildProcessObject = ytDlp.exec(url, {
+ useExtractors: ["default", "-generic"],
+ simulate: true,
+ quiet: true,
+ socketTimeout: 5,
+ extractorRetries: 0,
+ });
+ var done = false;
+ inline function finish(supported:Bool):Void {
+ if (done) return;
+ done = true;
+ callback(supported);
+ }
+ process.on("close", (code:Int, _signal:String) -> finish(code == 0));
+ process.on("error", (_err:Dynamic) -> finish(false));
+ return process;
+ }
+
+ /**
+ Spawns yt-dlp as a manual process and wires progress/completion to `handlers`.
+ Returns the process so the caller can kill it on cancel.
+ **/
+ public function download(url:String, options:ArgsOptions, handlers:YtdlpHandlers):ChildProcessObject {
+ final process:ChildProcessObject = ytDlp.exec(url, options);
+ var stderr = "";
+ var done = false;
+ process.stderr.on("data", (d:Dynamic) -> stderr += d);
+ process.on("progress", (p:VideoProgress) -> handlers.onProgress(p));
+ process.on("close", (code:Int, _signal:String) -> {
+ if (done) return;
+ done = true;
+ if (code == 0) handlers.onComplete();
+ else handlers.onError('yt-dlp exited with code $code: $stderr');
+ });
+ process.on("error", (err:Dynamic) -> {
+ if (done) return;
+ done = true;
+ handlers.onError('Failed to start yt-dlp process: $err');
+ });
+ return process;
+ }
+
+ /**
+ Generic best-quality download to mp4 for vk and other site-specific
+ extractors. Used after `checkUrlSupported` succeeds (or directly for vk).
+ **/
+ public function cacheVideo(client:Client, url:String, cb:CacheCallbacks):Void {
+ final clientName = client.name;
+ final outName = deriveOutName(url);
+ trace('Caching $url to $outName...');
+ cb.onResolved(cache.getFileUrl(outName));
+
+ final inName = '__tmp-${Std.random(0xFFFFFF)}-${Path.withoutExtension(outName)}';
+ var canceled = false;
+ var process:Null<ChildProcessObject> = null;
+ inline function cleanup():Void {
+ cleanInputFiles(inName);
+ }
+ if (cb.registerCancel != null) cb.registerCancel(() -> {
+ canceled = true;
+ process?.kill();
+ cleanup();
+ });
+
+ var useCookies = false;
+ var lastSentRatio = 0.0;
+
+ function onGetInfo(info:VideoInfo):Void {
+ if (canceled) return;
+ if (cb.onMetadata != null) cb.onMetadata(info.title, info.duration);
+ if (!cache.removeOlderCache(estimateSize(info) * 2 + cache.freeSpaceBlock)) {
+ cleanup();
+ cb.onError();
+ log(clientName, cache.notEnoughSpaceErrorText);
+ return;
+ }
+ process = download(url, {
+ output: '${cache.cacheDir}/$inName',
+ remuxVideo: "mp4",
+ additionalOptions: ytExtraOptions(),
+ cookies: useCookies ? getCookiesPathOrNull() : null,
+ forceIpv4: true,
+ socketTimeout: 5,
+ extractorRetries: 0,
+ }, {
+ onProgress: p -> {
+ if (canceled) return;
+ final ratio = p.status == "finished" ? 1.0 : (p.downloaded / p.total)
+ .clamp(0, 1);
+ if (ratio - lastSentRatio < 0.01 && ratio < 1) return;
+ lastSentRatio = ratio;
+ cb.onProgress(ratio.toFixed(4));
+ },
+ onComplete: () -> {
+ if (canceled) {
+ cleanup();
+ return;
+ }
+ final name = cache.findFile(n -> n.startsWith(inName)) ?? {
+ final err = 'Error: cannot find downloaded file with prefix $inName';
+ cache.logWithAdmins(client, err);
+ cb.onError();
+ return;
+ };
+ FileSystem.rename('${cache.cacheDir}/$name', '${cache.cacheDir}/$outName');
+ cleanup();
+ cache.add(outName);
+ cb.onComplete();
+ },
+ onError: err -> {
+ if (canceled) return;
+ cache.logWithAdmins(client, "Error during video download: " + err);
+ cleanup();
+ cb.onError();
+ }
+ });
+ }
+
+ getInfoAsync(url, useCookies).then(onGetInfo).catchError(err -> {
+ if (canceled) return;
+ useCookies = true;
+ getInfoAsync(url, useCookies).then(onGetInfo).catchError(err -> {
+ cleanup();
+ cb.onError();
+ log(clientName, "" + err);
+ });
+ });
+ }
+
+ function deriveOutName(url:String):String {
+ final decoded = try url.urlDecode() catch (e) url;
+ var last = decoded.substr(decoded.lastIndexOf("/") + 1);
+ final q = last.indexOf("?");
+ if (q != -1) last = last.substr(0, q);
+ last = Path.withoutExtension(last);
+ if (last == "") last = "video";
+ return cache.getFreeFileName('$last.mp4');
+ }
+
+ function estimateSize(info:VideoInfo):Int {
+ final d:Dynamic = info;
+ final size:Null<Float> = d.filesize_approx ?? d.filesize;
+ return Std.int(size ?? 0);
+ }
+
+ function log(clientName:String, msg:String):Void {
+ cache.logByName(clientName, msg);
+ }
+}
send patches to the email below
yukais@pinapelz.com
include the subject [PATCH repo_name]
pinapelz.com
homepage