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 + } +}