Add files via upload
This commit is contained in:
parent
942a3ec9db
commit
7605760bb5
8 changed files with 583 additions and 0 deletions
37
package.json
Normal file
37
package.json
Normal file
|
@ -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"
|
||||
}
|
||||
}
|
124
src/app.ts
Normal file
124
src/app.ts
Normal file
|
@ -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("<link>", "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("<link>", "youtube link")
|
||||
.option("-v <tag>", "pass the video tag you got from the get command")
|
||||
.option("-a <tag>", "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();
|
216
src/cli/functions.ts
Normal file
216
src/cli/functions.ts
Normal file
|
@ -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<IVideoObject>((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<IAudioObject>((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<string>((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;
|
||||
}
|
16
src/cli/interface.ts
Normal file
16
src/cli/interface.ts
Normal file
|
@ -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;
|
||||
}
|
83
src/cli/logs.ts
Normal file
83
src/cli/logs.ts
Normal file
|
@ -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);
|
||||
}
|
||||
}
|
57
src/cli/tables.ts
Normal file
57
src/cli/tables.ts
Normal file
|
@ -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());
|
||||
}
|
36
src/utils/ulits.ts
Normal file
36
src/utils/ulits.ts
Normal file
|
@ -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";
|
||||
// }
|
14
tsconfig.json
Normal file
14
tsconfig.json
Normal file
|
@ -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
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue