diff options
| author | RblSb <msrblsb@gmail.com> | 2025-01-16 03:07:31 +0300 |
|---|---|---|
| committer | RblSb <msrblsb@gmail.com> | 2025-01-17 01:00:09 +0300 |
| commit | d9ca7beaa9494cf34590853901cf8be44e243775 (patch) | |
| tree | f09ce979460bdf28363a922298283dfee0c506fb /src/server/Cache.hx | |
| parent | f84fdc40ba817b6a2d907484b1e1500197ceeafe (diff) | |
Cache on server feature
Server will download video from supported players and add as raw video to playlist (only youtube is supported for now).
Cache for YT player is available after installing optional dependencies, see readme. For cache size see `cacheStorageLimitGiB ` in config.
There is also minor ux improvement, latest checkbox states will be keeped in local storage now.
Diffstat (limited to 'src/server/Cache.hx')
| -rw-r--r-- | src/server/Cache.hx | 165 |
1 files changed, 165 insertions, 0 deletions
diff --git a/src/server/Cache.hx b/src/server/Cache.hx new file mode 100644 index 0000000..8348476 --- /dev/null +++ b/src/server/Cache.hx @@ -0,0 +1,165 @@ +package server; + +import js.lib.Promise; +import js.node.ChildProcess; +import js.node.Fs.Fs; +import js.node.stream.Readable; +import sys.FileSystem; +import utils.YoutubeUtils; + +class Cache { + final main:Main; + final cacheDir:String; + + public final cachedFiles:Array<String> = []; + + public final isYtReady = false; + + /** In bytes **/ + public var storageLimit = 3 * 1024 * 1024 * 1024; + + public function new(main:Main, cacheDir:String) { + this.main = main; + this.cacheDir = cacheDir; + Utils.ensureDir(cacheDir); + isYtReady = checkYtDeps(); + } + + 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 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)) { + callback(outName); + return; + } + final ytdl:Dynamic = untyped require("@distube/ytdl-core"); + log(client, 'Caching $url to $outName...'); + final opts = {playerClients: ["IOS", "WEB_CREATOR"]}; + final promise:Promise<YouTubeVideoInfo> = ytdl.getInfo(url, opts); + promise.then(info -> { + // trace(info.formats.filter(item -> item.audioCodec != null)); + 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); + return; + } + final videoFormat = getBestYoutubeVideoFormat(info.formats) ?? { + log(client, "Error: video format not found"); + trace(info.formats); + return; + } + trace("Picked audio and video formats"); + + final dlVideo:Readable<Dynamic> = ytdl(url, { + format: videoFormat, + playerClients: opts.playerClients + }); + dlVideo.pipe(Fs.createWriteStream('$cacheDir/input-video')); + dlVideo.on("error", err -> log(client, "Error during video download: " + err)); + + final dlAudio:Readable<Dynamic> = ytdl(url, { + format: audioFormat, + playerClients: opts.playerClients + }); + dlAudio.pipe(Fs.createWriteStream('$cacheDir/input-audio')); + dlAudio.on("error", err -> log(client, "Error during audio download: " + err)); + + var count = 0; + function onComplete(type:String):Void { + count++; + log(client, '$type track downloaded ($count/2)'); + if (count < 2) return; + final args = '-y -i input-video -i input-audio -c copy -map 0:v -map 1:a $outName'.split(" "); + final process = ChildProcess.spawn("ffmpeg", args, { + cwd: cacheDir, + stdio: "ignore" + }); + process.on("close", (code:Int) -> { + if (code != 0) { + log(client, 'Error: ffmpeg closed with code $code'); + return; + } + final inVideo = '$cacheDir/input-video'; + final inAudio = '$cacheDir/input-audio'; + if (FileSystem.exists(inVideo)) FileSystem.deleteFile(inVideo); + if (FileSystem.exists(inAudio)) FileSystem.deleteFile(inAudio); + + cachedFiles.push(outName); + removeOlderCache(); + + callback(outName); + }); + } + dlVideo.on("finish", () -> onComplete("Video")); + dlAudio.on("finish", () -> onComplete("Audio")); + // dlVideo.on('progress', (c, d, t) -> { + // final progress = Std.int((d / t * 100) * 10) / 10; + // trace(progress); + // }); + }).catchError(err -> { + log(client, "" + err); + }); + } + + function removeOlderCache():Void { + while (getUsedSpace() > storageLimit) { + final name = cachedFiles.shift(); + final path = '$cacheDir/$name'; + if (FileSystem.exists(path)) FileSystem.deleteFile(path); + } + } + + function getUsedSpace():Int { + var total = 0; + for (name in cachedFiles) { + final path = '$cacheDir/$name'; + total += FileSystem.stat(path).size; + } + return total; + } + + function getBestYoutubeVideoFormat(formats:Array<YoutubeVideoFormat>):Null<YoutubeVideoFormat> { + final qPriority = [1080, 720, 480, 360, 240]; + for (q in qPriority) { + final quality = '${q}p'; + for (format in formats) { + if (format.videoCodec == null) continue; + if (format.qualityLabel == quality) return format; + } + } + return null; + } +} |
