From 7605760bb504fb0a98315f2d76a286e8b8b5787d Mon Sep 17 00:00:00 2001
From: Sudo Space <79229394+sudospaes@users.noreply.github.com>
Date: Fri, 14 Jun 2024 20:58:06 +0330
Subject: [PATCH] Add files via upload
---
package.json | 37 ++++++++
src/app.ts | 124 +++++++++++++++++++++++++
src/cli/functions.ts | 216 +++++++++++++++++++++++++++++++++++++++++++
src/cli/interface.ts | 16 ++++
src/cli/logs.ts | 83 +++++++++++++++++
src/cli/tables.ts | 57 ++++++++++++
src/utils/ulits.ts | 36 ++++++++
tsconfig.json | 14 +++
8 files changed, 583 insertions(+)
create mode 100644 package.json
create mode 100644 src/app.ts
create mode 100644 src/cli/functions.ts
create mode 100644 src/cli/interface.ts
create mode 100644 src/cli/logs.ts
create mode 100644 src/cli/tables.ts
create mode 100644 src/utils/ulits.ts
create mode 100644 tsconfig.json
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..0775df6
--- /dev/null
+++ b/package.json
@@ -0,0 +1,37 @@
+{
+ "name": "lollipop",
+ "version": "dev",
+ "main": "app.js",
+ "keywords": [],
+ "author": "sudospace",
+ "license": "MIT",
+ "description": "lollipop is a friendly and lovely cli youtube downloader for you.",
+ "devDependencies": {
+ "@types/fluent-ffmpeg": "^2.1.24",
+ "@types/node": "^20.14.2",
+ "@yao-pkg/pkg": "^5.12.0",
+ "nodemon": "^3.1.3",
+ "ts-node": "^10.9.2",
+ "typescript": "^5.4.5"
+ },
+ "dependencies": {
+ "chalk": "^4.1.2",
+ "commander": "^12.1.0",
+ "easy-table": "^1.2.0",
+ "fluent-ffmpeg": "^2.1.3",
+ "moment": "^2.30.1",
+ "ytdl-core": "^4.11.5"
+ },
+ "scripts": {
+ "start": "node dist/app.js",
+ "dev": "nodemon src/app.ts",
+ "build": "tsc",
+ "build-w": "tsc -w",
+ "make-linux-x64": "npm run build && pkg --targets node20-linux-x64 --compress GZip dist/app.js -o lollipop-linux-x64",
+ "make-linux-arm64": "npm run build && pkg --targets node20-linux-arm64 --compress GZip dist/app.js -o lollipop-linux-arm64",
+ "make-win-x64": "npm run build && pkg --targets node20-win-x64 --compress GZip dist/app.js -o lollipop-win-x64",
+ "make-win-arm64": "npm run build && pkg --targets node20-win-arm64 --compress GZip dist/app.js -o lollipop-win-arm64",
+ "make-macos-x64": "npm run build && pkg --targets node20-macos-x64 --compress GZip dist/app.js -o lollipop-macos-x64",
+ "make-macos-arm64": "npm run build && pkg --targets node20-macos-arm64 --compress GZip dist/app.js -o lollipop-macos-arm64"
+ }
+}
diff --git a/src/app.ts b/src/app.ts
new file mode 100644
index 0000000..8c404c7
--- /dev/null
+++ b/src/app.ts
@@ -0,0 +1,124 @@
+import chalk from "chalk";
+import { Command, Option } from "commander";
+
+import {
+ downloadVideo,
+ linkInfomation,
+ checkLink,
+ isTagValid,
+ downloadAudio,
+ merging,
+ conevrtToMp3,
+ debug,
+} from "./cli/functions";
+import { Wrong } from "./cli/logs";
+
+const app = new Command();
+
+app
+ .name("lollipop")
+ .description(
+ `A ${chalk.cyan.bold.underline("friendly")} and ${chalk
+ .hex("#F075AA")
+ .bold.underline("lovely")} cli youtube downloader for you ${chalk.hex(
+ "#D04848"
+ )("<3")}`
+ )
+ .version(`0.0.1`, "--version")
+ .usage("[command]")
+ .option("--debug", "simple debugger, It's a joke XD")
+ .addOption(new Option("-h, --help").hideHelp());
+
+app
+ .command("get")
+ .description("show youtube link details for you :3")
+ .argument("", "youtube link")
+ .addOption(new Option("-h, --help").hideHelp())
+ .addHelpText(
+ "after",
+ `${chalk.green("\nExamples:")}
+ ${chalk.yellow("get")} youtube_link`
+ )
+ .action(async (link, options) => {
+ if (options.debug) {
+ debug.enable = true;
+ }
+ checkLink(link);
+ linkInfomation(link);
+ });
+
+app
+ .command("down")
+ .description("download youtube videos/audios for you :P")
+ .argument("", "youtube link")
+ .option("-v ", "pass the video tag you got from the get command")
+ .option("-a ", "pass the audio tag you got from the get command")
+ .option("--mp3", "this flag is only used together with -a flag")
+ .addOption(new Option("-h, --help").hideHelp())
+ .addHelpText(
+ "after",
+ `${chalk.green("\nExamples:")}
+ standard download => ${chalk.yellow(
+ "down"
+ )} youtube_link -v tag_number -a tag_number
+ ${chalk.hex("#DC84F3")(
+ `In standard download, I download video and audio separately and merging them with ffmpeg`
+ )}\n
+ download only video => ${chalk.yellow("down")} youtube_link -v tag_number\n
+ download only audio => ${chalk.yellow(
+ "down"
+ )} youtube_link -a tag_number --mp3
+ ${chalk.hex("#DC84F3")(
+ `If you use ${chalk.blue("--mp3")} with ${chalk.blue(
+ "-a"
+ )} flag, It will convert audio to mp3 with ffmpeg`
+ )}`
+ )
+ .action(async (link, options) => {
+ if (options.debug) {
+ debug.enable = true;
+ }
+ checkLink(link);
+ if (options.v && options.a) {
+ console.log("Please wait for tag validation...");
+ const isVideoTagValid = await isTagValid(link, options.v);
+ const isAudioTagValid = await isTagValid(link, options.a);
+ if (isVideoTagValid && isAudioTagValid) {
+ const video = await downloadVideo(link, options.v);
+ const audio = await downloadAudio(link, options.a);
+ if (options.mp3) {
+ audio.path = await conevrtToMp3(audio);
+ }
+ merging(video, audio);
+ } else {
+ if (!isVideoTagValid) {
+ Wrong.videoTagNotFound();
+ } else if (!isAudioTagValid) {
+ Wrong.audioTagNotFound();
+ }
+ }
+ } else if (options.v) {
+ console.log("Please wait for tag validation...");
+ const isVideoTagValid = await isTagValid(link, options.v);
+ if (isVideoTagValid) {
+ console.log("I will download only video...");
+ downloadVideo(link, options.v);
+ } else {
+ Wrong.videoTagNotFound();
+ }
+ } else if (options.a) {
+ console.log("Please wait for tag validation...");
+ const isAudioTagValid = await isTagValid(link, options.a);
+ if (isAudioTagValid) {
+ console.log("I will download only audio...");
+ const audio = await downloadAudio(link, options.a);
+ if (options.mp3) {
+ conevrtToMp3(audio);
+ }
+ } else {
+ Wrong.audioTagNotFound();
+ }
+ }
+ });
+
+app.parse();
diff --git a/src/cli/functions.ts b/src/cli/functions.ts
new file mode 100644
index 0000000..acb20ee
--- /dev/null
+++ b/src/cli/functions.ts
@@ -0,0 +1,216 @@
+import ytdl from "ytdl-core";
+import ffmpeg from "fluent-ffmpeg";
+
+import fs from "fs";
+import readline from "readline";
+import { join, dirname } from "path";
+
+import {
+ drawAudioQualityTable,
+ drawVideoQualityTable,
+ drawBasicTable,
+} from "./tables";
+import { Successful, Wrong } from "./logs";
+import { secondstoTime, kbToSize } from "../utils/ulits";
+import { IVideoObject, IAudioObject } from "./interface";
+
+export const debug = {
+ enable: false,
+};
+
+export async function linkInfomation(link: string) {
+ try {
+ const info = await ytdl.getInfo(link);
+ drawBasicTable(info);
+ drawVideoQualityTable(info.formats);
+ drawAudioQualityTable(info.formats);
+ } catch (err) {
+ Wrong.internet(err, debug.enable);
+ process.exit(1);
+ }
+}
+
+export async function downloadVideo(link: string, tag: string) {
+ let info: ytdl.videoInfo;
+ try {
+ info = await ytdl.getInfo(link);
+ } catch (err) {
+ Wrong.internet(err, debug.enable);
+ process.exit(1);
+ }
+ const title = info.videoDetails.title;
+ let extension = "";
+ let quality = "";
+ let codec = "";
+ console.log("I am detecting video...");
+ info.formats.forEach((item) => {
+ if (item.itag == +tag) {
+ extension = item.container;
+ quality = item.qualityLabel;
+ codec = item.codecs;
+ }
+ });
+ const path = join(
+ dirname(process.execPath),
+ `${title}-${quality}-${codec}-video.${extension}`
+ );
+ const tracker = {
+ start: Date.now(),
+ video: { downloaded: 0, total: Infinity },
+ };
+ return new Promise((resolve, reject) => {
+ ytdl(link, { filter: (format) => format.itag == +tag })
+ .on("progress", (_, downloaded, total) => {
+ tracker.video = { downloaded, total };
+ readline.clearLine(process.stdout, 0);
+ readline.cursorTo(process.stdout, 0);
+ process.stdout.write(
+ `Downloading video: ${kbToSize(
+ tracker.video.downloaded
+ )} / ${kbToSize(tracker.video.total)}`
+ );
+ })
+ .on("finish", () => {
+ Successful.videoDownloaded();
+ const time = `Average video download time: ${secondstoTime(
+ (Date.now() - tracker.start) / 1000
+ )}`;
+ console.log(time);
+ resolve({ path, title, extension, quality, codec });
+ })
+ .on("error", (err) => {
+ Wrong.errorOnVideoBuffers(err, debug.enable);
+ process.exit(1);
+ })
+ .pipe(fs.createWriteStream(path));
+ });
+}
+
+export async function downloadAudio(link: string, tag: string) {
+ let info: ytdl.videoInfo;
+ try {
+ info = await ytdl.getInfo(link);
+ } catch (err) {
+ Wrong.internet(err, debug.enable);
+ process.exit(1);
+ }
+ const title = info.videoDetails.title;
+ let extension = "";
+ let bitrate = 0;
+ let codec = "";
+ let channels = 0;
+ console.log("I am detecting audio...");
+ info.formats.forEach((item) => {
+ if (item.itag == +tag) {
+ extension = item.container;
+ bitrate = item.audioBitrate!;
+ codec = item.codecs;
+ channels = item.audioChannels!;
+ }
+ });
+ const path = join(
+ dirname(process.execPath),
+ `${title}-${bitrate}-${codec}-audio.${extension}`
+ );
+ const tracker = {
+ start: Date.now(),
+ audio: { downloaded: 0, total: Infinity },
+ };
+ return new Promise((resolve, reject) => {
+ ytdl(link, { filter: (format) => format.itag == +tag })
+ .on("progress", (_, downloaded, total) => {
+ tracker.audio = { downloaded, total };
+ readline.clearLine(process.stdout, 0);
+ readline.cursorTo(process.stdout, 0);
+ process.stdout.write(
+ `Downloaging audio: ${kbToSize(
+ tracker.audio.downloaded
+ )} / ${kbToSize(tracker.audio.total)}`
+ );
+ })
+ .on("finish", () => {
+ Successful.audioDownloaded();
+ const time = `Average video download time: ${secondstoTime(
+ (Date.now() - tracker.start) / 1000
+ )}`;
+ console.log(time);
+ resolve({ path, title, codec, extension, channels, bitrate });
+ })
+ .on("error", (err) => {
+ Wrong.errorOnAudioBuffers(err, debug.enable);
+ process.exit(1);
+ })
+ .pipe(fs.createWriteStream(path));
+ });
+}
+
+export function merging(video: IVideoObject, audio: IAudioObject) {
+ const path = `${video.title}-${video.quality}-${video.codec}.${video.extension}`;
+ ffmpeg()
+ .mergeAdd(video.path)
+ .mergeAdd(audio.path)
+ .save(join(dirname(process.execPath), path))
+ .on("progress", () => {
+ readline.clearLine(process.stdout, 0);
+ readline.cursorTo(process.stdout, 0);
+ process.stdout.write(`Merging video with audio...`);
+ })
+ .on("end", () => {
+ console.log("\nMerging Done :D");
+ fs.unlinkSync(video.path);
+ fs.unlinkSync(audio.path);
+ })
+ .on("error", (err) => {
+ Wrong.errorOnMerging(err, debug.enable);
+ process.exit(1);
+ });
+}
+
+export function conevrtToMp3(audio: IAudioObject) {
+ const path = `${audio.title}-${audio.bitrate}kb.mp3`;
+ return new Promise((resolve, reject) => {
+ ffmpeg()
+ .input(audio.path)
+ .format("mp3")
+ .audioBitrate(audio.bitrate)
+ .audioChannels(audio.channels)
+ .save(join(dirname(process.execPath), path))
+ .on("progress", () => {
+ readline.clearLine(process.stdout, 0);
+ readline.cursorTo(process.stdout, 0);
+ process.stdout.write(`Converting to mp3...`);
+ })
+ .on("end", () => {
+ console.log("\nAudio converted to mp3 file :D");
+ fs.unlinkSync(audio.path);
+ resolve(path);
+ })
+ .on("error", (err) => {
+ Wrong.errorOnAudioBuffers(err, debug.enable);
+ process.exit(1);
+ });
+ });
+}
+
+export function checkLink(link: string) {
+ if (!ytdl.validateURL(link)) {
+ Wrong.invalidLink();
+ process.exit(1);
+ }
+}
+
+export async function isTagValid(link: string, tag: string) {
+ let info: ytdl.videoInfo;
+ try {
+ info = await ytdl.getInfo(link);
+ } catch (err) {
+ Wrong.internet(err, debug.enable);
+ process.exit(1);
+ }
+ for (const item of info.formats) {
+ if (item.itag == +tag) {
+ return true;
+ }
+ }
+ return false;
+}
diff --git a/src/cli/interface.ts b/src/cli/interface.ts
new file mode 100644
index 0000000..4703264
--- /dev/null
+++ b/src/cli/interface.ts
@@ -0,0 +1,16 @@
+export interface IVideoObject {
+ path: string;
+ title: string;
+ extension: string;
+ quality: string;
+ codec: string;
+}
+
+export interface IAudioObject {
+ path: string;
+ title: string;
+ extension: string;
+ codec: string;
+ channels: number;
+ bitrate: number;
+}
diff --git a/src/cli/logs.ts b/src/cli/logs.ts
new file mode 100644
index 0000000..ef8e9dd
--- /dev/null
+++ b/src/cli/logs.ts
@@ -0,0 +1,83 @@
+import chalk from "chalk";
+
+export class Wrong {
+ static internet(err: any, debug: boolean) {
+ if (debug) {
+ console.log(err);
+ } else {
+ const output = `${chalk.hex("#F3B95F")(
+ "T~T"
+ )} Something wrong. check your ${chalk
+ .hex("#EE4E4E")
+ .underline("internet")} connection`;
+ console.log(output);
+ }
+ }
+ static errorOnMerging(err: any, debug: boolean) {
+ if (debug) {
+ console.log(err);
+ } else {
+ const output = `${chalk.red(
+ "Something wrong"
+ )} in merging to a file ${chalk.cyan("T~T")}`;
+ console.log(output);
+ }
+ }
+ static errorOnAudioBuffers(err: any, debug: boolean) {
+ if (debug) {
+ console.log(err);
+ } else {
+ const output = `${chalk.red(
+ "Something wrong"
+ )} in write audio buffers in file ${chalk.cyan("T~T")}`;
+ console.log(output);
+ }
+ }
+ static errorOnVideoBuffers(err: any, debug: boolean) {
+ if (debug) {
+ console.log(err);
+ } else {
+ const output = `${chalk.red(
+ "Something wrong"
+ )} in write video buffer in file ${chalk.cyan("T~T")}`;
+ console.log(output);
+ }
+ }
+ static invalidLink() {
+ const output = `${chalk.blue("T~T")} Honey, it seems you given me an ${chalk
+ .hex("#F3B95F")
+ .underline("invalid link")}`;
+ console.log(output);
+ }
+ static audioTagNotFound() {
+ const output = `${chalk.hex("#9BABB8")(
+ ":("
+ )} I can't find this audio tag. Please use ${chalk.yellow.underline(
+ "get"
+ )} command to see available tags`;
+ console.log(output);
+ }
+ static videoTagNotFound() {
+ const output = `${chalk.hex("#9BABB8")(
+ ":("
+ )} I can't find this video tag. Please use ${chalk.yellow.underline(
+ "get"
+ )} command to see available tags`;
+ console.log(output);
+ }
+}
+
+export class Successful {
+ static audioDownloaded() {
+ const output = `\n${chalk.green("YES!")} ${chalk.hex("#FF9B9B")(
+ ":D"
+ )} audio download has been done`;
+ console.log(output);
+ }
+ static videoDownloaded() {
+ const output = `\n${chalk.green("YES!")} ${chalk.hex("#FF9B9B")(
+ ":D"
+ )} video download has been done`;
+ console.log(output);
+ }
+}
diff --git a/src/cli/tables.ts b/src/cli/tables.ts
new file mode 100644
index 0000000..04297b1
--- /dev/null
+++ b/src/cli/tables.ts
@@ -0,0 +1,57 @@
+import { videoInfo, videoFormat } from "ytdl-core";
+import table from "easy-table";
+import chalk from "chalk";
+import moment from "moment";
+
+import { secondstoTime, kbToSize } from "../utils/ulits";
+
+export function drawBasicTable(info: videoInfo) {
+ const title = `${chalk.bold.inverse(" Title ")} ==> ${
+ info.videoDetails.title
+ }`;
+ const length = secondstoTime(+info.videoDetails.lengthSeconds);
+ const channelName = info.videoDetails.ownerChannelName;
+ const publishDate = info.videoDetails.publishDate;
+
+ const t = new table();
+ t.cell("Video Length", length);
+ t.cell("Channel name", channelName);
+ t.cell("Publish date", moment(publishDate).format("YYYY-MM-DD"));
+ t.newRow();
+
+ console.log("\n" + title);
+ console.log(t.toString());
+}
+
+export function drawVideoQualityTable(formats: videoFormat[]) {
+ const t = new table();
+ formats.forEach((i) => {
+ if (i.hasVideo && i.hasAudio == false) {
+ t.cell(chalk.cyan("Tag"), chalk.cyan(i.itag));
+ t.cell(chalk.magenta("Quality"), chalk.magenta(i.qualityLabel));
+ t.cell(chalk.blue("Format"), chalk.blue(i.container));
+ t.cell(chalk.green("Codec"), chalk.green(i.videoCodec));
+ t.cell(chalk.yellow("Size"), chalk.yellow(kbToSize(+i.contentLength)));
+ t.sort([`${chalk.blue("Format")}|des`]);
+ t.newRow();
+ }
+ });
+ console.log("Available video qualities");
+ console.log(t.toString());
+}
+
+export async function drawAudioQualityTable(formats: videoFormat[]) {
+ const t = new table();
+ formats.forEach((i) => {
+ if (i.hasAudio) {
+ t.cell(chalk.cyan("Tag"), chalk.cyan(i.itag));
+ t.cell(chalk.magenta("Bitrate"), chalk.magenta(i.audioBitrate));
+ t.cell(chalk.green("Codec"), chalk.green(i.audioCodec));
+ t.cell(chalk.yellow("Size"), chalk.yellow(kbToSize(+i.contentLength)));
+ t.sort([`${chalk.magenta("Bitrate")}|des`]);
+ t.newRow();
+ }
+ });
+ console.log("Available audio qualities");
+ console.log(t.toString());
+}
diff --git a/src/utils/ulits.ts b/src/utils/ulits.ts
new file mode 100644
index 0000000..9081a07
--- /dev/null
+++ b/src/utils/ulits.ts
@@ -0,0 +1,36 @@
+export function secondstoTime(s: number) {
+ const hours = Math.floor(s / 3600);
+ const minutes = Math.floor((s - hours * 3600) / 60);
+ const seconds = Math.floor(s - hours * 3600 - minutes * 60);
+
+ const time =
+ hours.toString().padStart(2, "0") +
+ ":" +
+ minutes.toString().padStart(2, "0") +
+ ":" +
+ seconds.toString().padStart(2, "0");
+
+ return time;
+}
+
+export function kbToSize(kilobit: number) {
+ if (Number.isNaN(kilobit)) {
+ return "Incalculable";
+ }
+ const megabytes = kilobit / 1024 / 1024;
+ if (megabytes >= 1024) {
+ const gigabyte = megabytes / 1024;
+ return gigabyte.toFixed(2) + "GB";
+ }
+ return megabytes.toFixed(2) + "MB";
+}
+
+//? It's for future :)
+// export function toSize(kilobyte: number) {
+// const megabytes = kilobyte / 1024;
+// if (megabytes >= 1024) {
+// const gigabyte = megabytes / 1024;
+// return gigabyte.toFixed(2) + "GB";
+// }
+// return megabytes.toFixed(2) + "MB";
+// }
diff --git a/tsconfig.json b/tsconfig.json
new file mode 100644
index 0000000..ab8ac73
--- /dev/null
+++ b/tsconfig.json
@@ -0,0 +1,14 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "lib": ["ES2022"],
+ "module": "CommonJS",
+ "esModuleInterop": true,
+ "forceConsistentCasingInFileNames": true,
+ "strict": true,
+ "skipLibCheck": true,
+ "rootDir": "src",
+ "outDir": "dist",
+ "removeComments": true
+ }
+}