diff --git a/package-lock.json b/package-lock.json index bb2819f..b085747 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6,16 +6,19 @@ "": { "name": "art-gen", "dependencies": { + "chalk": "^5.3.0", "chrome-launcher": "^0.15.2", + "colors": "^1.4.0", "jsmediatags": "^3.9.7", - "puppeteer-core": "^20.8.2" + "puppeteer-core": "^20.8.2", + "string-width": "^6.1.0" }, "bin": { "art-gen": "src/index.ts" }, "devDependencies": { "@types/jsmediatags": "^3.9.3", - "@types/node": "^20.4.2", + "@types/node": "^20.4.5", "typescript": "^5.1.6" } }, @@ -78,9 +81,9 @@ "dev": true }, "node_modules/@types/node": { - "version": "20.4.2", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.4.2.tgz", - "integrity": "sha512-Dd0BYtWgnWJKwO1jkmTrzofjK2QXXcai0dmtzvIBhcA+RsG5h8R3xlyta0kGOZRNfL9GuRtb1knmPEhQrePCEw==" + "version": "20.4.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.4.5.tgz", + "integrity": "sha512-rt40Nk13II9JwQBdeYqmbn2Q6IVTA5uPhvSO+JVqdXw/6/4glI6oR9ezty/A9Hg5u7JH4OmYmuQ+XvjKm0Datg==" }, "node_modules/@types/yauzl": { "version": "2.10.0", @@ -200,6 +203,17 @@ "node": "*" } }, + "node_modules/chalk": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.3.0.tgz", + "integrity": "sha512-dLitG79d+GV1Nb/VYcCDFivJeK1hiukt9QjRNVOsUtTy1rR1YJsmpGGTZ3qJos+uw7WmWF4wUwBd9jxjocFC2w==", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, "node_modules/chrome-launcher": { "version": "0.15.2", "resolved": "https://registry.npmjs.org/chrome-launcher/-/chrome-launcher-0.15.2.tgz", @@ -241,6 +255,24 @@ "node": ">=12" } }, + "node_modules/cliui/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" + }, + "node_modules/cliui/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -257,6 +289,14 @@ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" }, + "node_modules/colors": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/colors/-/colors-1.4.0.tgz", + "integrity": "sha512-a+UqTh4kgZg/SlGvfbzDHpgRu7AAQOmmqRHJnxhRZICKFUT91brVhNNt58CMWU9PsBbv3PDCZUHbVxuDiH2mtA==", + "engines": { + "node": ">=0.1.90" + } + }, "node_modules/cross-fetch": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-4.0.0.tgz", @@ -278,10 +318,15 @@ "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1135028.tgz", "integrity": "sha512-jEcNGrh6lOXNRJvZb9RjeevtZGrgugPKSMJZxfyxWQnhlKawMPhMtk/dfC+Z/6xNXExlzTKlY5LzIAK/fRpQIw==" }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==" + }, "node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" + "version": "10.2.1", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.2.1.tgz", + "integrity": "sha512-97g6QgOk8zlDRdgq1WxwgTMgEWGVAQvB5Fdpgc1MkNy56la5SKP9GsMXKDOdqwn90/41a8yPwIGk1Y6WVbeMQA==" }, "node_modules/end-of-stream": { "version": "1.4.4", @@ -726,16 +771,44 @@ } }, "node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-6.1.0.tgz", + "integrity": "sha512-k01swCJAgQmuADB0YIc+7TuatfNvTBVOoaUWJjTB9R4VJzR5vNWzf5t42ESVZFPS8xTySF7CAdV4t/aaIm3UnQ==", "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" + "eastasianwidth": "^0.2.0", + "emoji-regex": "^10.2.1", + "strip-ansi": "^7.0.1" }, "engines": { - "node": ">=8" + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width/node_modules/ansi-regex": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz", + "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/string-width/node_modules/strip-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, "node_modules/strip-ansi": { @@ -831,6 +904,24 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/wrap-ansi/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" + }, + "node_modules/wrap-ansi/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", @@ -897,6 +988,24 @@ "node": ">=12" } }, + "node_modules/yargs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" + }, + "node_modules/yargs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/yauzl": { "version": "2.10.0", "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", diff --git a/package.json b/package.json index f58c9ce..1cc0b6d 100644 --- a/package.json +++ b/package.json @@ -8,13 +8,16 @@ "dev": "tsc -w -p ./tsconfig.build.json" }, "dependencies": { + "chalk": "^5.3.0", "chrome-launcher": "^0.15.2", + "colors": "^1.4.0", "jsmediatags": "^3.9.7", - "puppeteer-core": "^20.8.2" + "puppeteer-core": "^20.8.2", + "string-width": "^6.1.0" }, "devDependencies": { "@types/jsmediatags": "^3.9.3", - "@types/node": "^20.4.2", + "@types/node": "^20.4.5", "typescript": "^5.1.6" } -} \ No newline at end of file +} diff --git a/src/aligner.ts b/src/aligner.ts new file mode 100644 index 0000000..2c9c62b --- /dev/null +++ b/src/aligner.ts @@ -0,0 +1,92 @@ +'use strict' + +import stringWidth from "string-width"; + +interface alignOpts { + + align: string + split: string + pad: string + +} + +export function defaultAlignOpts(): alignOpts { + return { + align: "center", + split: "\n", + pad: " " + } +} + +export class Aligner { + + private opts: alignOpts; + + constructor(opts: alignOpts) { + this.opts = opts; + } + + public align(text: string): string { + return ansiAlign(text, this.opts); + } + +} + +function ansiAlign(text: any, opts: alignOpts): string { + if (typeof text == "undefined") return "" + + opts = opts || {} + const align = opts.align || 'center' + + // short-circuit `align: 'left'` as no-op + if (align === 'left') return text + + const split = opts.split || '\n' + const pad = opts.pad || ' ' + const widthDiffFn = align !== 'right' ? halfDiff : fullDiff + var textArray: string[] = []; + + let returnString = false + if (!Array.isArray(text)) { + returnString = true + textArray = String(text).split(split) + } + + let width + let maxWidth = process.stdout.columns; + textArray = textArray.map(function (str) { + str = String(str) + width = stringWidth(str) + maxWidth = Math.max(width, maxWidth) + return { + str, + width + } + }).map(function (obj) { + return new Array(widthDiffFn(maxWidth, obj.width) + 1).join(pad) + obj.str + }) + + return returnString ? textArray.join(split) : text +} + +ansiAlign.left = function left(text: string): string { + return ansiAlign(text, { align: 'left', split: "\n", pad: " " }) +} + +ansiAlign.center = function center(text: string): string { + return ansiAlign(text, { align: 'center', split: "\n", pad: " " }) +} + +ansiAlign.right = function right(text: string): string { + return ansiAlign(text, { align: 'right', split: "\n", pad: " " }) +} + +export default ansiAlign; + +function halfDiff(maxWidth: number, curWidth: number): number { + return Math.floor((maxWidth - curWidth) / 2) +} + +function fullDiff(maxWidth: number, curWidth: number): number { + return maxWidth - curWidth +} diff --git a/src/args.ts b/src/args.ts index a44fa32..0e9caac 100644 --- a/src/args.ts +++ b/src/args.ts @@ -1,19 +1,34 @@ const COMMAND_PATTERN = /^--|-/; const ARTWORK_ONLY_PATTERN = /^--artwork-only|-a$/; const OVERWRITE_PATTERN = /^--overwrite|-w$/; +const DEBUG_PATTERN = /^--debug|-d$/; +const THREADED_PATTERN = /^--thread|t$/; +const RECURSIVE_PATTERN = /^--recursive|r$/; const args = process.argv.slice(2); const commands: string[] = []; export const inputs: string[] = []; -for (const item of args){ - switch (true){ +for (const item of args) { + switch (true) { case ARTWORK_ONLY_PATTERN.test(item): case OVERWRITE_PATTERN.test(item): { commands.push(item); break; } + case DEBUG_PATTERN.test(item): { + commands.push(item); + break; + } + case THREADED_PATTERN.test(item): { + commands.push(item); + break; + } + case RECURSIVE_PATTERN.test(item): { + commands.push(item); + break; + } case COMMAND_PATTERN.test(item): { throw new Error(`Unexpected command '${item}'`); } @@ -23,9 +38,12 @@ for (const item of args){ } } -if (inputs.length === 0){ +if (inputs.length === 0) { throw new Error("Must provide song file path inputs"); } export const artworkOnly: boolean = commands.some(item => ARTWORK_ONLY_PATTERN.test(item)); -export const overwrite: boolean = commands.some(item => OVERWRITE_PATTERN.test(item)); \ No newline at end of file +export const overwrite: boolean = commands.some(item => OVERWRITE_PATTERN.test(item)); +export const debugMode: boolean = commands.some(item => DEBUG_PATTERN.test(item)); +export const threaded: boolean = commands.some(item => THREADED_PATTERN.test(item)); +export const recursive: boolean = commands.some(item => RECURSIVE_PATTERN.test(item)); \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index c81511d..2aa28d4 100755 --- a/src/index.ts +++ b/src/index.ts @@ -1,59 +1,293 @@ #!/usr/bin/env npx tsx -import { extname } from "node:path"; +import { createInterface } from "node:readline"; +import { extname, basename, join, dirname } from "node:path"; import { exec as execCallback } from "node:child_process"; import { promisify } from "node:util"; -import { inputs, artworkOnly, overwrite } from "./args.js"; +import { inputs, artworkOnly, overwrite, recursive, debugMode, threaded } from "./args.js"; import { createRenderer } from "./thumbnail.js"; +import { existsSync, readdirSync, rmSync, statSync } from "node:fs"; +import { readFile, rm } from "node:fs/promises"; +import { defaultLoggerOpts, newLogger } from "./logger.js"; +import chalk from "chalk"; +import { readTags } from "./jsmediatags.js"; +import { cpus, totalmem } from "node:os"; +import { Timer as _T } from "./timing.js"; const exec = promisify(execCallback); -console.log("Art Gen"); -console.log("-- An app to generate thumbnails for YouTube Art Tracks! --\n"); +export const Logger = newLogger(defaultLoggerOpts(debugMode)); -if (artworkOnly) console.log("[artwork only]"); -if (overwrite) console.log("[overwrite]"); +const termed: string[] = []; +const allowed: string[] = []; -const outputs = inputs.map(item => extRename(item,artworkOnly ? ".png" : ".mp4")); +const threadQueue: string[] = []; +const queueAddList: string[] = []; + +const maxThreads = Math.max(Math.floor((cpus().length - 2) * ((totalmem() / 1e+9) / 24)), 1); +if (threaded) Logger.debug("[threaded] MAX=" + maxThreads); + +const Timer = new _T(); +Timer.start(); + +let queueCallback: Function | null; +type DynamicFuncObj = { + [key: string]: Function +} +type DynamicArrayObj = { + [key: string]: Array +} +const queueCallbackList: DynamicFuncObj = {} + +Logger.log(chalk.bold("Art Gen")); +Logger.log("-- An app to generate thumbnails for YouTube Art Tracks! --\n"); + +if (artworkOnly) Logger.debug("[artwork only]"); +if (overwrite) Logger.debug("[overwrite]"); + +const loadedFolders: DynamicArrayObj = {} +var cycleFolders = async function (i: number = 0) { + try { + var recurs = async (dir: string = inputs[i], splice: boolean = true) => { + if (statSync(dir).isDirectory()) { + Logger.debug(`${splice ? "F" : "Subf"}older detected ${dir}`); + loadedFolders[dir] = []; + if (splice) inputs.splice(i, 1); + var rdir = readdirSync(dir, { recursive }); + rdir.forEach((file) => { + var fname = file.toString(); + if (statSync(join(dir, fname)).isDirectory() && recursive) recurs(join(dir, fname), false); + if (extname(fname) == '.mp3' && inputs.indexOf(join(dir, fname)) < 0) { + inputs.push(join(dir, fname)); + Logger.debug(`${fname} found in ${dir}`); + loadedFolders[dir].push(fname); + } + }); + } + } + recurs(); + } catch (e) { } + if (!!inputs[i + 1]) await cycleFolders(i + 1); +} +await cycleFolders(); + +for (const key in loadedFolders) { + var coloredNames = loadedFolders[key]; + coloredNames.forEach((n, i) => { + coloredNames[i] = chalk.greenBright(n); + }); + if (loadedFolders[key].length > 0) Logger.info(`Found: ${coloredNames.join(", ")} in ${chalk.hex("#F30BE5")(key)}`); +} + +Logger.debug(inputs); + +const outputs = inputs.map(item => extRename(item, artworkOnly ? ".png" : ".mp4")); const renderer = await createRenderer(); -for (let i = 0; i < inputs.length; i++){ +if (threaded) Logger.log(`Generating thumbnails...\n`); +var cycleSongs = async function (i: number = 0) { const songPath = inputs[i]; - const thumbnailPath = artworkOnly ? outputs[i] : extRename(outputs[i],".png"); - await renderer.generateThumbnail(songPath,thumbnailPath); + const thumbnailPath = artworkOnly ? outputs[i] : extRename(outputs[i], ".png"); + const song = await readFile(songPath); + const tags = await readTags(song); + const { title, artist, album } = tags; + if (!threaded) Logger.log(`${title}: ${artist} - ${album}`); + var _overwrite: boolean = false; + if ((existsSync(thumbnailPath) || (existsSync(outputs[i]) && !artworkOnly)) && !overwrite) { + _overwrite = await (async () => { + Timer.stop(); + return new Promise(async (r) => { + var response = null; + if (threaded) Logger.log(`${title}: ${artist} - ${album}`); + var _prompt_ = async () => { + var t = await prompt("File already exists! Would you like to overwrite? (Y/N): "); + if (t.toUpperCase() == "Y" || t.toUpperCase() == "N") { + response = t.toUpperCase(); + if (t.toUpperCase() == "N") { + Logger.log("Skipping file..."); + Logger.lineBreak(); + term(songPath); + } + } else { + Logger.warning('Invalid response! Answer with "Y" or "N"!\n'); + await _prompt_(); + } + } + await _prompt_(); + if (threaded) Logger.lineBreak(); + Logger.debug(`${response}`); + r(response == "Y"); + }); + })(); + } else { + _overwrite = true; + } + Timer.start(); + if (!threaded) { + if (_overwrite || overwrite) await renderer.generateThumbnail(songPath, thumbnailPath, overwrite || _overwrite, threaded); + } else { + await addToQueue(songPath); + if (_overwrite || overwrite) renderer.generateThumbnail(songPath, thumbnailPath, overwrite || _overwrite, threaded); + } + if (!!inputs[i + 1]) await cycleSongs(i + 1); } +await cycleSongs(); +await waitForQueue(); + await renderer.close(); -if (artworkOnly) process.exit(0); +if (artworkOnly) end(); -for (let i = 0; i < inputs.length; i++){ - const songPath = inputs[i]; - const thumbnailPath = extRename(outputs[i],".png"); +Logger.debugLineBreak(); +const tempArtwork: boolean = await (async () => { + Timer.stop(); + return new Promise(async (r) => { + var response = null; + var _prompt_ = async () => { + var t = await prompt(`Delete artwork after video${inputs.length > 1 ? "s" : ""} ${inputs.length > 1 ? "are" : "is"} generated? (Y/N): `); + if (t.toUpperCase() == "Y" || t.toUpperCase() == "N") { + response = t.toUpperCase(); + } else { + Logger.warning('Invalid response! Answer with "Y" or "N"!\n'); + await _prompt_(); + } + } + await _prompt_(); + Logger.lineBreak(); + Logger.debug(`${response}`); + r(response == "Y"); + }); +})(); +Timer.start(); + +Logger.debug(`${tempArtwork}`); + +for (let i: number = 0; i < inputs.length; i++) { + const songPath: string = inputs[i]; + if (termed.indexOf(songPath) >= 0) { + Logger.log(basename(songPath) + " skipped! Overwrite denied."); + continue; + } + const thumbnailPath = extRename(outputs[i], ".png"); const videoPath = outputs[i]; - console.log("Generating video..."); - // console.log(songPath); - // console.log(thumbnailPath); - // console.log(videoPath); - await exec(`ffmpeg \ - -loop 1 \ - -framerate 1 \ - -i "${thumbnailPath}" \ - -i "${songPath}" \ - -map 0 \ - -map 1:a \ - -c:v libx264 \ - -preset ultrafast \ - -tune stillimage \ - -vf "scale=out_color_matrix=bt709,fps=10,format=yuv420p" \ - -c:a aac \ - -shortest \ - "${videoPath}"\ - ${overwrite ? "-y" : "-n"}`); + Logger.debug(songPath); + Logger.debug(thumbnailPath); + Logger.debug(videoPath); + if (!threaded) { + Logger.log(basename(songPath) + " | Generating video..."); + await exec(`ffmpeg \ + -loop 1 \ + -framerate 1 \ + -i "${thumbnailPath}" \ + -i "${songPath}" \ + -map 0 \ + -map 1:a \ + -preset ultrafast \ + -c:v libx264 \ + -vf "scale=out_color_matrix=bt709,fps=10,format=yuv420p" \ + -tune stillimage \ + -shortest \ + -c:a aac \ + "${videoPath}"\ + ${allowed.indexOf(songPath) >= 0 ? "-y" : "-n"}`); + if (tempArtwork) { + Logger.debug("Removing " + thumbnailPath); + rmSync(thumbnailPath); + } + } else { + await addToQueue(songPath); + (async (): Promise => { + Logger.log(basename(songPath) + " | Generating video..."); + return new Promise(async (r) => { + await exec(`ffmpeg \ + -loop 1 \ + -framerate 1 \ + -i "${thumbnailPath}" \ + -i "${songPath}" \ + -map 0 \ + -map 1:a \ + -c:v libx264 \ + -preset ultrafast \ + -tune stillimage \ + -vf "scale=out_color_matrix=bt709,fps=10,format=yuv420p" \ + -c:a aac \ + -shortest \ + "${videoPath}"\ + ${allowed.indexOf(songPath) >= 0 ? "-y" : "-n"}`); + Logger.debug(tempArtwork); + if (tempArtwork) { + Logger.debug("Removing " + thumbnailPath); + await rm(thumbnailPath); + } + queueFinish(songPath); + r(); + }); + })(); + } } +await waitForQueue(); + +end(); + function extRename(path: string, ext: string): string { const extension = extname(path); - return path.replace(extension,ext); + return path.replace(extension, ext); +} + +export async function prompt(prompt: string): Promise { + const rl = createInterface({ + input: process.stdin, + output: process.stdout + }); + + return new Promise(resolve => rl.question(chalk.greenBright(prompt), ans => { + rl.close(); + resolve(ans); + })); +} + +export function term(songPath: string) { + termed.push(songPath); +} +export function allow(songPath: string) { + allowed.push(songPath); +} +export function end() { + Timer.stop(); + Logger.log(`Done! ${(Timer.getTime() / 1000).toLocaleString(undefined, { minimumFractionDigits: 3 })}s elapsed`); + Logger.critical("Exiting process..."); + Timer.reset(); + process.exit(0); +} +export async function waitForQueue(): Promise { + if (threadQueue.length < 1) return new Promise(r => r()); + return new Promise(r => { + queueCallback = r; + }); +} +export function queueFinish(songPath: string) { + threadQueue.splice(threadQueue.indexOf(songPath), 1); + if (queueAddList.length > 0 && threadQueue.length < maxThreads) { + var add = queueAddList.shift()!; + threadQueue.push(add); + queueCallbackList[add](); + delete queueCallbackList[add]; + } + if (threadQueue.length < 1 && !!queueCallback) { + queueCallback(); + queueCallback = null; + } +} +export async function addToQueue(songPath: string): Promise { + if (threadQueue.length < maxThreads) { + threadQueue.push(songPath); + return new Promise(r => r()); + } + queueAddList.push(songPath); + return new Promise(r => { + queueCallbackList[songPath] = r; + }); } \ No newline at end of file diff --git a/src/logger.ts b/src/logger.ts new file mode 100644 index 0000000..6fba6cb --- /dev/null +++ b/src/logger.ts @@ -0,0 +1,203 @@ +import chalk from "chalk"; +import { Aligner, defaultAlignOpts } from "./aligner"; + +interface LoggerOpts { + + format: string; + debugLevel: number + center: boolean + +} + +export const LevelColors = [ + "blue", + "gray", + "yellow", + "red", + "bgRed" +] +export const Levels = [ + 'INFO', + 'DEBUG', + 'WARNING', + 'ERROR', + 'CRITICAL' +] + +export function newLogger(opts: LoggerOpts): Logger { + return new Logger(opts); +} +export function defaultLoggerOpts(debug: boolean = false): LoggerOpts { + return { + format: "[%level%]%sp% %message%", + debugLevel: debug ? 1 : 0, + center: false + } +} + +class Logger { + + private format: string; + private debugLevel: number; + private center: boolean; + private aligner: Aligner + + constructor(opts: LoggerOpts) { + console.clear(); + this.format = opts.format; + this.debugLevel = opts.debugLevel; + this.center = opts.center; + this.aligner = new Aligner(defaultAlignOpts()) + } + + public getFormat(): string { + return this.format; + } + public debugEnabled(): boolean { + return this.debugLevel > 0; + } + + public log(...messages: any) { + this.info(...messages); + } + public info(...messages: any) { + for (var i in messages) { + var message = messages[i]; + console.log(this.formatMessage(message, 0)); + } + } + public debug(...messages: any) { + for (var i in messages) { + var message = messages[i]; + if (this.debugEnabled()) console.log(this.formatMessage(message, 1)); + } + } + public warning(...messages: any) { + for (var i in messages) { + var message = messages[i]; + console.log(this.formatMessage(message, 2)); + } + } + public error(...messages: any) { + for (var i in messages) { + var message = messages[i]; + console.log(this.formatMessage(message, 3)); + } + } + public critical(...messages: any) { + for (var i in messages) { + var message = messages[i]; + console.log(this.formatMessage(message, 4)); + } + } + + public debugLineBreak(count: number = 1) { + if (this.debugEnabled()) for (var i = 0; i < count; i++) console.log(); + } + public lineBreak(count: number = 1) { + for (var i = 0; i < count; i++) console.log(); + } + + + + private formatMessage(message: any, level: number): any { + if (!["string", "boolean", "number"].includes(typeof message)) { + console.log(typeof message); + var parts = ["[%level%]"] + if (this.center) { + switch (LevelColors[level]) { + case "blue": + parts[0] = chalk.blue.dim.bgBlack(parts[0]); + break; + case "gray": + parts[0] = chalk.gray.dim.bgBlack(parts[0]); + break; + case "yellow": + parts[0] = chalk.yellow.dim.bgBlack(parts[0]); + break; + case "red": + parts[0] = chalk.redBright.dim.bgBlack(parts[0]); + break; + case "bgRed": + parts[0] = chalk.red.dim.bold(parts[0]); + break; + } + } else { + switch (LevelColors[level]) { + case "blue": + parts[0] = chalk.blue.bgBlack(parts[0]); + break; + case "gray": + parts[0] = chalk.gray.bgBlack(parts[0]); + break; + case "yellow": + parts[0] = chalk.yellow.bgBlack(parts[0]); + break; + case "red": + parts[0] = chalk.redBright.bgBlack(parts[0]); + break; + case "bgRed": + parts[0] = chalk.red.bold(parts[0]); + break; + } + } + parts[0] = parts[0].split("%level%").join(Levels[level]); + if (this.center) { + console.log(this.aligner.align(parts[0])); + return this.aligner.align(message); + } + console.log(parts[0]); + return message; + } + message = message.toString(); + var parts = this.format.split('%sp%'); + if (this.center) { + switch (LevelColors[level]) { + case "blue": + parts[0] = chalk.blue.dim.bgBlack(parts[0]); + break; + case "gray": + parts[0] = chalk.gray.dim.bgBlack(parts[0]); + break; + case "yellow": + parts[0] = chalk.yellow.dim.bgBlack(parts[0]); + break; + case "red": + parts[0] = chalk.redBright.dim.bgBlack(parts[0]); + break; + case "bgRed": + parts[0] = chalk.red.dim.bold(parts[0]); + break; + } + } else { + switch (LevelColors[level]) { + case "blue": + parts[0] = chalk.blue.bgBlack(parts[0]); + break; + case "gray": + parts[0] = chalk.gray.bgBlack(parts[0]); + break; + case "yellow": + parts[0] = chalk.yellow.bgBlack(parts[0]); + break; + case "red": + parts[0] = chalk.redBright.bgBlack(parts[0]); + break; + case "bgRed": + parts[0] = chalk.red.bold(parts[0]); + break; + } + } + + parts[0] = parts[0].split("%level%").join(Levels[level]); + parts[1] = parts[1].split("%message%").join(message); + + if (this.center) { + console.log(this.aligner.align(parts[0])); + return this.aligner.align(parts[1]); + } + + return parts.join(""); + } + +} \ No newline at end of file diff --git a/src/thumbnail.ts b/src/thumbnail.ts index 8f6d3f1..ee8426f 100644 --- a/src/thumbnail.ts +++ b/src/thumbnail.ts @@ -2,56 +2,58 @@ import { createServer } from "node:http"; import { launch } from "puppeteer-core"; import { getChromePath } from "chrome-launcher"; import { readFile, writeFile } from "node:fs/promises"; -import { resolve } from "node:path"; +import { basename, resolve } from "node:path"; import { overwrite } from "./args.js"; import { readTags } from "./jsmediatags.js"; import type { Server } from "node:http"; import type { Browser } from "puppeteer-core"; import type { MediaTags } from "./jsmediatags.js"; +import { Logger, allow, prompt, queueFinish, term } from "./index.js"; +import { existsSync } from "node:fs"; const SERVER_PATH = "http://localhost:3000"; export async function createRenderer(): Promise { const server = await startServer(); const browser = await launchBrowser(); - return new ThumbnailGenerator(server,browser); + return new ThumbnailGenerator(server, browser); } async function startServer(): Promise { - const server = createServer(async (request,response) => { + const server = createServer(async (request, response) => { const url = new URL(`${SERVER_PATH}${request.url}`); - // console.log(url.toString()); + Logger.debug(url.toString()); if (url.searchParams.size === 0) return new Promise(resolve => response.end(resolve)); const songPath = decodeURIComponent(url.searchParams.get("songPath")!); - console.log(`\n${songPath}`); + const threaded = decodeURIComponent(url.searchParams.get("threaded")!) == "true"; + Logger.debug(`${songPath}\n`); const song = await readFile(songPath); const tags = await readTags(song); - const source = await generateSource(tags); - response.writeHead(200,{ "Content-Type": "text/html" }); + const source = await generateSource(tags, threaded); + response.writeHead(200, { "Content-Type": "text/html" }); response.write(source); await new Promise(resolve => response.end(resolve)); }); - await new Promise(resolve => server.listen(3000,resolve)); + await new Promise(resolve => server.listen(3000, resolve)); return server; } -async function generateSource(tags: MediaTags): Promise { - const index = new URL("../index.html",import.meta.url); - const source = await readFile(index,{ encoding: "utf-8" }); +async function generateSource(tags: MediaTags, threaded: boolean): Promise { + const index = new URL("../index.html", import.meta.url); + const source = await readFile(index, { encoding: "utf-8" }); const { title, artist, album, artwork } = tags; - console.log(`${title}: ${artist} - ${album}`); - console.log("Generating thumbnail..."); + if (!threaded) Logger.log(`${threaded ? title + " | " : ""}Generating thumbnail...\n`); return source - .replaceAll("%TITLE%",title) - .replaceAll("%ARTIST%",artist) - .replaceAll("%ALBUM%",album) - .replaceAll("%ARTWORK%",artwork); + .replaceAll("%TITLE%", title) + .replaceAll("%ARTIST%", artist) + .replaceAll("%ALBUM%", album) + .replaceAll("%ARTWORK%", artwork); } async function launchBrowser(): Promise { const executablePath = getChromePath(); - return launch({ headless: "new", executablePath }); + return launch({ headless: true, executablePath }); } class ThumbnailGenerator { @@ -63,17 +65,25 @@ class ThumbnailGenerator { this.#browser = browser; } - async generateThumbnail(songPath: string, thumbnailPath: string): Promise { - const page = await this.#browser.newPage(); - const renderPath = new URL(SERVER_PATH); - renderPath.searchParams.set("songPath",encodeURIComponent(resolve(songPath))); + async generateThumbnail(songPath: string, thumbnailPath: string, overwrite: boolean, threaded: boolean): Promise { + Logger.debug(songPath); + return new Promise(async (_resolve) => { + const page = await this.#browser.newPage(); + const renderPath = new URL(SERVER_PATH); + renderPath.searchParams.set("songPath", encodeURIComponent(resolve(songPath))); + renderPath.searchParams.set("threaded", encodeURIComponent(threaded)); - await page.goto(renderPath.toString(),{ waitUntil: "networkidle0" }); - await page.setViewport({ width: 1920, height: 1080 }); + await page.goto(renderPath.toString(), { waitUntil: "networkidle0" }); + await page.setViewport({ width: 1920, height: 1080 }); - const thumbnail = await page.screenshot(); - // console.log(thumbnail); - await writeFile(thumbnailPath,thumbnail,{ flag: overwrite ? undefined : "wx" }); + const thumbnail = await page.screenshot(); + Logger.debug(thumbnail); + + await writeFile(thumbnailPath, thumbnail); + allow(songPath); + queueFinish(songPath); + _resolve(overwrite); + }); } async close(): Promise { diff --git a/src/timing.ts b/src/timing.ts new file mode 100644 index 0000000..d32998f --- /dev/null +++ b/src/timing.ts @@ -0,0 +1,31 @@ +import { Logger } from "."; + +export class Timer { + + private addedTime: number = 0; + private _start: number = 0; + + public start() { + if (this._start > 0) return; + this._start = Date.now(); + } + + public stop() { + this.addedTime += Date.now() - this._start; + this._start = 0; + } + + public resume() { + this.start(); + } + + public reset() { + this.stop() + this.addedTime = 0; + } + + public getTime(): number { + return this.addedTime; + } + +} \ No newline at end of file