Skip to content

Commit

Permalink
Merge pull request #5 from tahsinature/add-audio-compression
Browse files Browse the repository at this point in the history
Add audio compression
  • Loading branch information
tahsinature authored Dec 15, 2024
2 parents 18c169b + ea8d003 commit 8d1e9f1
Show file tree
Hide file tree
Showing 9 changed files with 233 additions and 89 deletions.
Binary file modified bun.lockb
Binary file not shown.
4 changes: 1 addition & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,16 +17,14 @@
},
"devDependencies": {
"@types/bun": "latest",
"@types/cli-progress": "^3.11.6",
"@types/prompts": "^2.4.9"
"@types/cli-progress": "^3.11.6"
},
"peerDependencies": {
"typescript": "^5.0.0"
},
"dependencies": {
"cli-progress": "^3.12.0",
"meow": "^13.2.0",
"prompts": "^2.4.2",
"readline": "^1.3.0"
}
}
5 changes: 5 additions & 0 deletions src/blueprints.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,9 @@ export class File {
if (this.outputFullPath) fs.promises.unlink(this.outputFullPath);
else console.error(`Output file not found for ${this.originalFullPath}`);
}

getOutputFileSizeInMB() {
if (!this.outputFullPath) throw new Error("Output file not found");
return fs.statSync(this.outputFullPath).size / 1024 / 1024;
}
}
38 changes: 29 additions & 9 deletions src/check.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { Operations } from "./types";
import { runShellCommandAndReturnLine } from "./utils";

const checkIfBinaryExists = async (binary: string) => {
Expand All @@ -6,26 +7,45 @@ const checkIfBinaryExists = async (binary: string) => {
return lines.length > 0;
};

const requiredBinaries = [
const commonRequiredBinaries = [
{
name: "gum",
purpose: "To convert audio files to mp3.",
howTo: `Install gum from: https://github.com/charmbracelet/gum`,
},
];

const requiredBinariesForVideo = [
...commonRequiredBinaries,
{
name: "HandBrakeCLI",
purpose: "HandBrake Command Line Version to encode videos.",
howTo: `Download HandBrake Command Line Version from: https://handbrake.fr/downloads.php`,
},
];

const requiredBinariesForAudio = [
...commonRequiredBinaries,
{
name: "fzf",
purpose: "Fuzzy Finder to select multiple files.",
howTo: `Install fzf from: https://github.com/junegunn/fzf#installation`,
name: "ffmpeg",
purpose: "FFmpeg to encode audio files.",
howTo: `See github: https://github.com/FFmpeg/FFmpeg and find installation instructions for your OS.`,
},
{
name: "fd",
purpose: "To find and filter files.",
howTo: `Install fd from: https://github.com/sharkdp/fd#installation`,
name: "split",
purpose: "To split files.",
howTo: `Search google and install split for your OS.`,
},
];

export const checkRequiredBinaries = async () => {
const mappedByOp = {
[Operations.VIDEO_COMPRESS]: requiredBinariesForVideo,
[Operations.AUDIO_COMPRESS]: requiredBinariesForAudio,
};

export const checkRequiredBinaries = async (op: Operations) => {
const missing = [];
const requiredBinaries = mappedByOp[op] as { name: string; purpose: string; howTo: string }[];

for (const binary of requiredBinaries) {
const isFound = await checkIfBinaryExists(binary.name);
Expand All @@ -34,7 +54,7 @@ export const checkRequiredBinaries = async () => {

if (missing.length) {
console.error(`The following binaries are required to run this program:
----------`);
----------`);
for (const binary of missing) {
console.error(`- ${binary.name}: ${binary.purpose}`);
console.error(binary.howTo);
Expand Down
61 changes: 59 additions & 2 deletions src/engine.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import os from "os";
import path from "path";
import fs from "fs";
import type { File } from "./blueprints";
import { ProgressBar } from "./progress";
import { runShellCommandAndReturnLine } from "./utils";
import { createDirOvewriteRecursive, escapePath, runShellCommandAndReturnLine, runShellCommandSimple } from "./utils";
import { askBoolean, askInteger } from "./prompts";

const getProgressAndRemainingTime = (line: string) => {
const progressMatch = line.match(/Encoding: task 1 of 1, (\d+\.\d+) %/);
Expand All @@ -13,7 +16,7 @@ const getProgressAndRemainingTime = (line: string) => {
return { progress, remainingTime };
};

export const main = async (files: File[], preset: string, { keepAudio = true }) => {
export const compressVideo = async (files: File[], preset: string, { keepAudio = true }) => {
const audioFlag = keepAudio ? "" : "-a none";

for (const file of files) {
Expand All @@ -39,3 +42,57 @@ export const main = async (files: File[], preset: string, { keepAudio = true })

progressBar.stop();
};

const splitToPart = (filePath: string, maxMb: number) => {
const tempDir = escapePath(os.tmpdir());
const uuid = crypto.randomUUID();
const dateNow = new Date().toISOString().split("T")[0].replace(/-/g, "");
const partDir = path.join(tempDir, `${dateNow}__${uuid}`);
fs.mkdirSync(partDir);
const partPath = path.join(partDir, `rec_part_`);
const splitCmd = `split -b ${maxMb}M ${escapePath(filePath)} "${partPath}"`;
runShellCommandSimple(splitCmd);

return partDir;
};

const convertPartToMp3 = (partDir: string) => {
const files = fs.readdirSync(partDir);

for (const file of files) {
const fullFilePath = path.join(partDir, file);
const mp3Loc = `${escapePath(fullFilePath)}.mp3`;
const cmd = `ffmpeg -i ${escapePath(fullFilePath)} -c:a copy ${mp3Loc}`;
runShellCommandSimple(cmd);
}
};

export const compressAudio = async (file: File, bitrate: string) => {
const outputFileName = `${file.fileNameWithoutExtension}_compressed_${bitrate}.mp3`;
file.outputFullPath = path.resolve(file.dir, outputFileName);
file.cmd = `ffmpeg -i ${escapePath(file.originalFullPath)} -map 0:a:0 -b:a ${bitrate} ${escapePath(file.outputFullPath)} -y`;

if (!file.cmd) throw new Error(`Command not found for file: ${file.originalFullPath}`);
runShellCommandSimple(file.cmd);

const outputSize = file.getOutputFileSizeInMB();
const shouldSplit = await askBoolean(`Converted size: ${outputSize.toFixed(2)} MB. Do you want to split the file?`, outputSize > 4 ? "true" : "false");
if (shouldSplit) {
const dirName = `${file.fileNameWithoutExtension}_split`;
const dirLoc = path.resolve(file.dir, dirName);
createDirOvewriteRecursive(dirLoc);

const splitByMB = await askInteger("Input size in MB", 4);
const partDir = splitToPart(file.outputFullPath, splitByMB);
convertPartToMp3(partDir);

const mp3Files = fs.readdirSync(partDir).filter((f) => f.endsWith(".mp3"));
for (const mp3File of mp3Files) {
const mp3FileLoc = path.join(partDir, mp3File);
const newLoc = path.resolve(dirLoc, mp3File);
fs.renameSync(mp3FileLoc, newLoc);
}

fs.rmdirSync(partDir, { recursive: true });
}
};
49 changes: 35 additions & 14 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,33 +1,54 @@
import { askFiles, askPreset, askToggle } from "./prompts";
import { main } from "./engine";
import { askBoolean, askChoose, askFiles, askFilter, askPreset } from "./prompts";
import { compressVideo, compressAudio } from "./engine";
import { checkRequiredBinaries } from "./check";
import { Operations } from "./types";

const hardbrake = async () => {
await checkRequiredBinaries();
const videoCompress = async () => {
await checkRequiredBinaries(Operations.VIDEO_COMPRESS);

const files = await askFiles();
if (files.length === 0) {
console.log("No files selected. Exiting.");
process.exit(0);
}
const files = await askFiles(["mp4", "mkv", "avi", "mov", "flv", "wmv", "webm", "m4v", "lrf"]);

const preset = await askPreset();
const keepAudio = await askToggle("Do you want to keep the audio?", { initial: true });

await main(files, preset, { keepAudio });
const keepAudio = await askBoolean("Do you want to keep the audio?", "true");

const happyWithResults = await askToggle("Are you happy with the results?", { initial: true });
await compressVideo(files, preset, { keepAudio });

const happyWithResults = await askBoolean("Are you happy with the results?", "true");
if (!happyWithResults) {
for (const file of files) await file.deleteOutput();
console.log("Deleted all the output files.");
process.exit(0);
}

const deleteOriginalFiles = await askToggle("Do you want to delete the original files?", { initial: false });
const deleteOriginalFiles = await askBoolean("Do you want to delete the original files?", "false");
if (deleteOriginalFiles) {
for (const file of files) await file.deleteOriginal();
console.log("Deleted all the original files.");
}
};

export default hardbrake;
const audioCompress = async () => {
await checkRequiredBinaries(Operations.AUDIO_COMPRESS);

const files = await askFiles(["mp3", "wav", "flac", "m4a", "aac", "ogg", "wma", "aiff", "alac"]);
const bitrate = await askFilter("Select a bitrate", ["16k", "32k", "64k", "128k", "256k", "320k"], { limit: 1, min: 1 });

for (const file of files) {
await compressAudio(file, bitrate[0]);
}
};

const fnMap: Record<Operations, () => Promise<void>> = {
[Operations.VIDEO_COMPRESS]: videoCompress,
[Operations.AUDIO_COMPRESS]: audioCompress,
};

const root = async () => {
const choice = await askChoose("Select an operation", Object.values(Operations));
const op = choice[0] as Operations;

await fnMap[op]();
};

export default root;
115 changes: 67 additions & 48 deletions src/prompts.ts
Original file line number Diff line number Diff line change
@@ -1,69 +1,88 @@
import prompts from "prompts";
import path from "path";
import { getPresets, runShellCommand } from "./utils";
import fs from "fs";
import { getPresets, runShellCommandAndReturnOutput } from "./utils";
import { File } from "./blueprints";

function promptsWithOnCancel<T extends string = string>(questions: prompts.PromptObject<T> | Array<prompts.PromptObject<T>>) {
return prompts(questions, {
onCancel: () => {
process.exit(0);
},
});
}
export const askFiles = async (supportedExtensions: string[]) => {
const dir = await askFolderPath();
const files = fs.readdirSync(dir);
const videos = files.filter((file) => supportedExtensions.includes(file.split(".").pop() || ""));
if (videos.length === 0) throw new Error("No files found in the folder");
const selectedVids = await askFilter("Choose videos", videos, { min: 1 });
if (selectedVids.length === 0) throw new Error("No files selected");

export const askFiles = async () => {
const supportedExt = ["mp4", "mkv", "avi", "mov", "flv", "wmv", "webm", "m4v", "lrf"];
const extentionsFlags = supportedExt.map((ext) => `-e ${ext}`).join(" ");
const command = `fd ${extentionsFlags} -t f --max-depth 1 --absolute-path . | fzf --multi --bind ctrl-space:toggle-all`;
return selectedVids.map((file) => new File(path.join(dir, file)));
};

export const askFolderPath = async () => {
console.log("Select a directory or, a file. If a file is selected, the parent directory will be used.");

const files = await runShellCommand(command);
if (!files) return [];
const command = `gum file --all --directory .`;
const result = runShellCommandAndReturnOutput(command);
if (result.length === 0) throw new Error("Nothing selected");
let dirPath = result[0];

const filtered = files.split("\n").filter(Boolean);
return filtered.map((file) => path.resolve(file)).map((file) => new File(file));
const isDir = fs.lstatSync(dirPath).isDirectory();
if (!isDir) dirPath = path.dirname(dirPath);
return dirPath;
};

const askAutoComplete = async (message: string, choices: string[]) => {
const response = await promptsWithOnCancel({
type: "autocomplete",
name: "value",
message,
choices: choices.map((choice) => ({ title: choice, value: choice })),
});
export const askChoose = async (message: string, choices: string[], { limit = 1 }: { limit?: number } = {}) => {
try {
const gumChoices = choices.join(",");
let command = `gum choose {${gumChoices}}`;

if (limit) command += ` --limit ${limit}`;
else command += " --no-limit";

return response.value;
return runShellCommandAndReturnOutput(command);
} catch (error: any) {
console.error(error.message);
process.exit(1);
}
};

export const askFilter = async (message: string, choices: string[], { limit = 0, min = 0 }: { limit?: number; min?: number } = {}) => {
try {
const gumChoices = choices.join("\n");
let command = `echo "${gumChoices}" | gum filter`;

if (limit) command += ` --limit ${limit}`;
else command += " --no-limit";

const selected = runShellCommandAndReturnOutput(command);
if (selected.length < min) throw new Error("Minimum number of choices not selected");
return selected;
} catch (error: any) {
console.error(error.message);
process.exit(1);
}
};

const fullPresetList = await getPresets();
const categories = Object.keys(fullPresetList);

export const askPreset = async () => {
const category = await askAutoComplete("Select a category", categories);
const presets = fullPresetList[category];
const preset = await askAutoComplete("Select a preset", presets);
const category = await askFilter("Select a category", categories, { limit: 1, min: 1 });
const presets = fullPresetList[category[0]];
const preset = await askFilter("Select a preset", presets, { limit: 1, min: 1 });

return preset;
return preset[0];
};

export const askBoolean = async (message: string) => {
const response = await promptsWithOnCancel({
type: "confirm",
name: "answer",
message,
});

return response.answer;
export const askBoolean = async (message: string, initial?: "true" | "false") => {
const defaultFlag = initial ? `--default=${initial}` : "";
const command = `gum confirm ${defaultFlag} "${message}" && echo "true" || echo "false"`;
const response = runShellCommandAndReturnOutput(command);
return response[0] === "true";
};

export const askToggle = async (message: string, { initial = false } = {}) => {
const response = await promptsWithOnCancel({
type: "toggle",
name: "value",
message,
initial,
active: "yes",
inactive: "no",
});

return response.value;
export const askInteger = async (message: string, initial?: number) => {
const initialFlag = initial ? `--value=${initial}` : "";
const command = `gum input --placeholder="${message}" ${initialFlag}`;
const response = runShellCommandAndReturnOutput(command);
const nm = parseInt(response[0]);
if (isNaN(nm)) throw new Error("Invalid number");

return nm;
};
4 changes: 4 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export enum Operations {
AUDIO_COMPRESS = "AUDIO_COMPRESS",
VIDEO_COMPRESS = "VIDEO_COMPRESS",
}
Loading

0 comments on commit 8d1e9f1

Please sign in to comment.