├── .nvmrc ├── .husky ├── commit-msg └── pre-commit ├── src ├── index.ts ├── __tests__ │ ├── FormatConverter.test.ts │ ├── SongTagsSearch.test.ts │ ├── utils.test.ts │ └── Downloader.test.ts ├── FormatConverter.ts ├── utils.ts ├── SongTagsSearch.ts └── Downloader.ts ├── .gitignore ├── tsup.config.js ├── tsconfig.json ├── eslint.config.js ├── .github └── workflows │ ├── release.yaml │ └── test.yaml ├── vitest.config.js ├── LICENSE ├── bin └── ytdl-mp3.cjs ├── README.md ├── package.json └── CHANGELOG.md /.nvmrc: -------------------------------------------------------------------------------- 1 | lts/jod -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | pnpm exec commitlint --edit $1 4 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | pnpm exec prettier-pre-commit 4 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Downloader'; 2 | export * from './FormatConverter'; 3 | export * from './SongTagsSearch'; 4 | export { YtdlMp3Error } from './utils'; 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # logs 2 | *.log 3 | npm-debug.log* 4 | .pnpm-debug.log* 5 | 6 | # dependencies 7 | node_modules/ 8 | 9 | # build 10 | dist/ 11 | 12 | # test coverage 13 | coverage/ 14 | -------------------------------------------------------------------------------- /tsup.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'tsup'; 2 | 3 | export default defineConfig({ 4 | clean: true, 5 | dts: true, 6 | entry: ['src/index.ts'], 7 | format: ['cjs', 'esm'], 8 | sourcemap: true 9 | }); 10 | -------------------------------------------------------------------------------- /src/__tests__/FormatConverter.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest'; 2 | 3 | import { FormatConverter } from '../FormatConverter'; 4 | 5 | describe('FormatConverter', () => { 6 | it('should be defined', () => { 7 | expect(FormatConverter).toBeDefined(); 8 | }); 9 | }); 10 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["@douglasneuroinformatics/tsconfig"], 3 | "compilerOptions": { 4 | "allowSyntheticDefaultImports": true, 5 | "checkJs": true, 6 | "module": "ESNext", 7 | "moduleResolution": "Bundler" 8 | }, 9 | "include": ["eslint.config.js", "tsup.config.js", "vitest.config.js", "src/**/*"] 10 | } 11 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import { config } from '@douglasneuroinformatics/eslint-config'; 2 | 3 | export default config( 4 | { 5 | env: { 6 | browser: false, 7 | es2021: true, 8 | node: true 9 | }, 10 | typescript: { 11 | enabled: true 12 | } 13 | }, 14 | { 15 | rules: { 16 | 'no-console': 'off' 17 | } 18 | } 19 | ); 20 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: Release 2 | on: 3 | push: 4 | branches: ['main'] 5 | workflow_dispatch: 6 | permissions: 7 | contents: read 8 | jobs: 9 | release: 10 | permissions: 11 | contents: write 12 | issues: write 13 | pull-requests: write 14 | id-token: write 15 | uses: DouglasNeuroinformatics/semantic-release/.github/workflows/release.yaml@main 16 | secrets: 17 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 18 | with: 19 | build-command: pnpm build 20 | lint-command: pnpm lint 21 | test-command: pnpm test 22 | -------------------------------------------------------------------------------- /vitest.config.js: -------------------------------------------------------------------------------- 1 | import * as path from 'path'; 2 | 3 | import { defineConfig } from 'vitest/config'; 4 | 5 | export default defineConfig({ 6 | test: { 7 | coverage: { 8 | exclude: ['**/*{.,-}{test,test-d,spec}.?(c|m)[jt]s?(x)', 'src/index.ts'], 9 | include: ['src/**/*'], 10 | provider: 'v8', 11 | reportsDirectory: path.resolve(import.meta.dirname, 'coverage'), 12 | skipFull: true, 13 | thresholds: { 14 | branches: 70, 15 | functions: 70, 16 | lines: 70, 17 | statements: 70 18 | } 19 | }, 20 | watch: false 21 | } 22 | }); 23 | -------------------------------------------------------------------------------- /src/FormatConverter.ts: -------------------------------------------------------------------------------- 1 | import cp from 'child_process'; 2 | import fs from 'fs'; 3 | 4 | import ffmpeg from 'ffmpeg-static'; 5 | 6 | import { YtdlMp3Error } from './utils'; 7 | 8 | export class FormatConverter { 9 | private readonly ffmpegBinary: string; 10 | 11 | constructor() { 12 | if (!ffmpeg) { 13 | throw new YtdlMp3Error('Failed to resolve ffmpeg binary'); 14 | } 15 | this.ffmpegBinary = ffmpeg; 16 | } 17 | 18 | videoToAudio(videoData: Buffer, outputFile: string): void { 19 | if (fs.existsSync(outputFile)) { 20 | throw new YtdlMp3Error(`Output file already exists: ${outputFile}`); 21 | } 22 | cp.execSync(`${this.ffmpegBinary} -loglevel 24 -i pipe:0 -vn -sn -c:a mp3 -ab 192k ${outputFile}`, { 23 | input: videoData 24 | }); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /.github/workflows/test.yaml: -------------------------------------------------------------------------------- 1 | name: Test 2 | on: 3 | pull_request: 4 | branches: ['main'] 5 | types: [opened, synchronize] 6 | push: 7 | branches: ['main'] 8 | workflow_dispatch: 9 | jobs: 10 | ci: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Checkout Repository 14 | uses: actions/checkout@v4 15 | - name: Setup PNPM 16 | uses: pnpm/action-setup@v3 17 | - name: Setup Node 18 | uses: actions/setup-node@v4 19 | with: 20 | cache: 'pnpm' 21 | node-version-file: '.nvmrc' 22 | - name: Install Dependencies 23 | run: pnpm install --frozen-lockfile 24 | - name: Lint 25 | run: pnpm run lint 26 | - name: Test 27 | run: pnpm run test:coverage 28 | - name: Upload Coverage 29 | if: github.event_name != 'pull_request' 30 | uses: codecov/codecov-action@v4 31 | with: 32 | directory: ./coverage/ 33 | token: ${{ secrets.CODECOV_TOKEN }} 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Joshua Unrau 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /src/__tests__/SongTagsSearch.test.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import { describe, expect, it, vi } from 'vitest'; 3 | 4 | import { SongTagsSearch } from '../SongTagsSearch'; 5 | 6 | describe('SongTagsSearch', () => { 7 | describe('search', () => { 8 | it('should handle an http status code of 500', async () => { 9 | vi.spyOn(axios, 'get').mockRejectedValueOnce({ response: { status: 500 } }); 10 | const searcher = new SongTagsSearch('My Video'); 11 | await expect(() => searcher.search()).rejects.toThrow('Call to iTunes API returned status code 500'); 12 | }); 13 | it('should handle an undefined http status code', async () => { 14 | vi.spyOn(axios, 'get').mockRejectedValueOnce({}); 15 | const searcher = new SongTagsSearch('My Video'); 16 | await expect(() => searcher.search()).rejects.toThrow('Call to iTunes API failed and did not return a status'); 17 | }); 18 | it('should handle a results count of zero', async () => { 19 | vi.spyOn(axios, 'get').mockResolvedValueOnce({ data: { resultCount: 0 } }); 20 | const searcher = new SongTagsSearch('My Video'); 21 | await expect(() => searcher.search()).rejects.toThrow('Call to iTunes API did not return any results'); 22 | }); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /bin/ytdl-mp3.cjs: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const { Command } = require('commander'); 4 | 5 | const { description, name, version } = require('../package.json'); 6 | const { Downloader, YtdlMp3Error } = require('../dist/index.cjs'); 7 | 8 | const program = new Command(); 9 | program.name(name); 10 | program.description(description); 11 | program.version(version); 12 | program.allowExcessArguments(false); 13 | program.argument('', 'url of video to download'); 14 | program.option('-o --output-dir ', 'path to output directory', Downloader.defaultDownloadsDir); 15 | program.option('-n --no-get-tags', 'skip extracting/applying id3 tags'); 16 | program.option('-v --verify-tags', 'verify id3 tags fetched from itunes'); 17 | program.option('-s --silent-mode', 'skip console output'); 18 | program.option('--verbose', 'enable verbose mode'); 19 | program.parse(); 20 | 21 | (async function () { 22 | const options = program.opts(); 23 | try { 24 | const downloader = new Downloader(options); 25 | await downloader.downloadSong(program.args[0]); 26 | } catch (err) { 27 | if (err instanceof YtdlMp3Error) { 28 | if (options.verbose) { 29 | console.error(err.cause); 30 | console.error(err.stack); 31 | } 32 | console.error(`ERROR: ${err.message}`); 33 | process.exit(1); 34 | } 35 | throw err; 36 | } 37 | })(); 38 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import readline from 'readline'; 3 | 4 | /** 5 | * Removes content within square brackets or parentheses, including the brackets and 6 | * parentheses themselves, along with any surrounding whitespace, from the given string. 7 | * 8 | * @param input the input string from which to remove content within brackets and parentheses. 9 | * @returns a new string with content within brackets and parentheses removed. 10 | */ 11 | export function removeParenthesizedText(s: string): string { 12 | // This loop is to handle nested nested brackets (see test for examples) 13 | const regex = /\s*([[(][^[\]()]*[\])])\s*/g; 14 | while (regex.test(s)) { 15 | s = s.replace(regex, ''); 16 | } 17 | return s; 18 | } 19 | 20 | /** 21 | * Checks if the given path corresponds to a directory. 22 | * 23 | * @param path - the path to check. 24 | * @returns `true` if the path exists and is a directory, `false` otherwise. 25 | */ 26 | export function isDirectory(path: string): boolean { 27 | return fs.existsSync(path) && fs.lstatSync(path).isDirectory(); 28 | } 29 | 30 | /** 31 | * Prompts the user for input via stdin and returns a promise that resolves to the user's input. 32 | * 33 | * @param prompt - the prompt text displayed to the user. 34 | * @param defaultInput - optional default input pre-filled in the prompt. 35 | * @returns a promise that resolves to the user's input 36 | */ 37 | export async function userInput(prompt: string, defaultInput?: string): Promise { 38 | const rl = readline.createInterface({ 39 | input: process.stdin, 40 | output: process.stdout 41 | }); 42 | return new Promise((resolve, reject) => { 43 | rl.question(prompt, (response) => { 44 | rl.close(); 45 | if (response) { 46 | resolve(response); 47 | } else { 48 | reject(new YtdlMp3Error('Invalid response: ' + response)); 49 | } 50 | }); 51 | rl.write(defaultInput ?? ''); 52 | }); 53 | } 54 | 55 | /** 56 | * Custom error class representing unrecoverable errors intentionally thrown by ytdl-mp3 57 | */ 58 | export class YtdlMp3Error extends Error { 59 | constructor(message: string, options?: ErrorOptions) { 60 | super(message, options); 61 | this.name = 'YtdlMp3Error'; 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 |
3 |

ytdl-mp3

4 |

5 | An NPM package to facilitate downloading music from YouTube, including automatic retrieval of ID3 tags and album art via the iTunes public API. 6 |
7 | Report Bug 8 | · 9 | Request Feature 10 |

11 |
12 | 13 | 14 |
15 | 16 | ![license](https://img.shields.io/github/license/joshunrau/ytdl-mp3) 17 | ![version](https://img.shields.io/github/package-json/v/joshunrau/ytdl-mp3) 18 | [![codecov](https://codecov.io/gh/joshunrau/ytdl-mp3/graph/badge.svg?token=T35BBZ7Q42)](https://codecov.io/gh/joshunrau/ytdl-mp3) 19 | 20 |
21 |
22 | 23 | ## Installation 24 | 25 | ```shell 26 | npm install -g ytdl-mp3 27 | ``` 28 | 29 | ## Usage 30 | 31 | ### Command-Line 32 | 33 | The easiest way to use ytdl-mp3 is through the command-line interface. Users must enter the URL of the YouTube video they wish to download. The title of the music video is then used to automatically retrieve ID3 tags (e.g., title, artist) and the associated cover art from iTunes. There is no need for the title of the YouTube video to follow a specific naming convention. 34 | 35 | ``` 36 | Usage: ytdl-mp3 [options] 37 | 38 | A NodeJS package and command-line tool for downloading music from YouTube, including automatic retrieval of ID3 tags and album art via iTunes. 39 | 40 | Arguments: 41 | url url of video to download 42 | 43 | Options: 44 | -V, --version output the version number 45 | -o --output-dir path to output directory 46 | -n --no-get-tags skip extracting/applying id3 tags 47 | -v --verify-tags verify id3 tags fetched from itunes 48 | -s --silent-mode skip console output 49 | -h, --help display help for command 50 | ``` 51 | 52 | ### ESM 53 | 54 | ```javascript 55 | import { Downloader } from 'ytdl-mp3'; 56 | 57 | const downloader = new Downloader({ 58 | getTags: true 59 | }); 60 | 61 | await downloader.downloadSong('https://www.youtube.com/watch?v=7jgnv0xCv-k'); 62 | ``` 63 | 64 | ### CommonJS 65 | 66 | ```javascript 67 | const { Downloader } = require('ytdl-mp3'); 68 | 69 | async function main() { 70 | const downloader = new Downloader({ 71 | getTags: true 72 | }); 73 | await downloader.downloadSong('https://www.youtube.com/watch?v=7jgnv0xCv-k'); 74 | } 75 | 76 | main(); 77 | ``` 78 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ytdl-mp3", 3 | "type": "module", 4 | "version": "5.2.2", 5 | "packageManager": "pnpm@10.5.2", 6 | "description": "An NPM package to facilitate downloading music from YouTube, including automatic retrieval of ID3 tags and album art via the iTunes public API.", 7 | "author": "Joshua Unrau", 8 | "license": "MIT", 9 | "homepage": "https://github.com/joshunrau/ytdl-mp3#readme", 10 | "repository": { 11 | "type": "git", 12 | "url": "https://github.com/joshunrau/ytdl-mp3.git" 13 | }, 14 | "bugs": { 15 | "url": "https://github.com/joshunrau/ytdl-mp3/issues" 16 | }, 17 | "keywords": [ 18 | "youtube", 19 | "download", 20 | "mp3", 21 | "music" 22 | ], 23 | "exports": { 24 | ".": { 25 | "types": "./dist/index.d.ts", 26 | "import": "./dist/index.js", 27 | "require": "./dist/index.cjs" 28 | } 29 | }, 30 | "main": "./dist/index.cjs", 31 | "module": "./dist/index.js", 32 | "types": "./dist/index.d.ts", 33 | "bin": { 34 | "ytdl-mp3": "./bin/ytdl-mp3.cjs" 35 | }, 36 | "files": [ 37 | "LICENSE", 38 | "README.md", 39 | "bin", 40 | "dist", 41 | "package.json" 42 | ], 43 | "engines": { 44 | "node": ">=20.0.0" 45 | }, 46 | "scripts": { 47 | "build": "tsup", 48 | "clean": "rm -rf coverage dist node_modules", 49 | "format": "prettier --write src", 50 | "lint": "tsc && eslint --fix src", 51 | "prepare": "husky", 52 | "test": "vitest", 53 | "test:coverage": "vitest --coverage" 54 | }, 55 | "dependencies": { 56 | "@distube/ytdl-core": "^4.16.4", 57 | "axios": "^1.8.1", 58 | "commander": "^13.1.0", 59 | "ffmpeg-static": "^5.2.0", 60 | "node-id3": "0.2.5" 61 | }, 62 | "devDependencies": { 63 | "@douglasneuroinformatics/eslint-config": "^5.3.1", 64 | "@douglasneuroinformatics/prettier-config": "^0.0.2", 65 | "@douglasneuroinformatics/semantic-release": "^0.2.1", 66 | "@douglasneuroinformatics/tsconfig": "^1.0.2", 67 | "@types/ffmpeg-static": "^3.0.3", 68 | "@types/node": "^22.x", 69 | "@vitest/coverage-v8": "^3.0.7", 70 | "eslint": "^9.21.0", 71 | "husky": "^9.1.7", 72 | "prettier": "^3.5.2", 73 | "tsup": "^8.4.0", 74 | "typescript": "5.6.x", 75 | "vitest": "^3.0.7" 76 | }, 77 | "commitlint": { 78 | "extends": [ 79 | "@douglasneuroinformatics/semantic-release/commitlint-config" 80 | ] 81 | }, 82 | "prettier": "@douglasneuroinformatics/prettier-config", 83 | "release": { 84 | "extends": [ 85 | "@douglasneuroinformatics/semantic-release" 86 | ] 87 | }, 88 | "pnpm": { 89 | "peerDependencyRules": { 90 | "allowedVersions": { 91 | "undici": "7.x" 92 | } 93 | }, 94 | "onlyBuiltDependencies": [ 95 | "esbuild", 96 | "ffmpeg-static" 97 | ] 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /src/__tests__/utils.test.ts: -------------------------------------------------------------------------------- 1 | import readline from 'readline'; 2 | 3 | import { beforeEach, describe, expect, it, vi } from 'vitest'; 4 | import type { MockedFunction } from 'vitest'; 5 | 6 | import { isDirectory, removeParenthesizedText, userInput, YtdlMp3Error } from '../utils'; 7 | 8 | describe('removeParenthesizedText', () => { 9 | it('removes content within square brackets', () => { 10 | expect(removeParenthesizedText('Hello [world]')).toBe('Hello'); 11 | }); 12 | it('removes content within double square brackets', () => { 13 | expect(removeParenthesizedText('Hello [[world]]')).toBe('Hello'); 14 | }); 15 | it('removes content within parentheses', () => { 16 | expect(removeParenthesizedText('Hello (world)')).toBe('Hello'); 17 | }); 18 | it('removes content within double parentheses', () => { 19 | expect(removeParenthesizedText('Hello (world)')).toBe('Hello'); 20 | }); 21 | it('removes nested content within brackets and parentheses', () => { 22 | expect(removeParenthesizedText('Hello (beautiful [world])')).toBe('Hello'); 23 | expect(removeParenthesizedText('Hello [beautiful (world)]')).toBe('Hello'); 24 | }); 25 | it('handles strings without brackets or parentheses', () => { 26 | expect(removeParenthesizedText('Hello World')).toBe('Hello World'); 27 | }); 28 | it('removes brackets and parentheses when they are the only characters', () => { 29 | expect(removeParenthesizedText('[()]')).toBe(''); 30 | }); 31 | it('handles an empty string', () => { 32 | expect(removeParenthesizedText('')).toBe(''); 33 | }); 34 | }); 35 | 36 | describe('isDirectory', () => { 37 | it('should return true for an existing directory', () => { 38 | expect(isDirectory(import.meta.dirname)).toBe(true); 39 | }); 40 | it('should return true for an existing file', () => { 41 | expect(isDirectory(import.meta.filename)).toBe(false); 42 | }); 43 | it('should return true for a non-existent file', () => { 44 | expect(isDirectory('/dev/null/foo.js')).toBe(false); 45 | }); 46 | }); 47 | 48 | describe('userInput', () => { 49 | let question: MockedFunction<(query: string, callback: (answer: string) => void) => void>; 50 | 51 | beforeEach(() => { 52 | question = vi.fn(); 53 | vi.spyOn(readline, 'createInterface').mockReturnValueOnce({ 54 | close: vi.fn(), 55 | question, 56 | write: vi.fn() 57 | } as any); 58 | }); 59 | 60 | it('should return the response to the question, if it is a non-empty string', async () => { 61 | question.mockImplementationOnce((_, callback) => callback('hello')); 62 | const response = await userInput('Please enter a response: '); 63 | expect(response).toBe('hello'); 64 | }); 65 | it('should throw an error if the response is an empty string', async () => { 66 | question.mockImplementationOnce((_, callback) => callback('')); 67 | await expect(() => userInput('Please enter a response: ')).rejects.toBeInstanceOf(YtdlMp3Error); 68 | }); 69 | }); 70 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## [5.2.2](https://github.com/joshunrau/ytdl-mp3/compare/v5.2.1...v5.2.2) (2025-03-01) 4 | 5 | ### Bug Fixes 6 | 7 | * update dependencies ([a25cb58](https://github.com/joshunrau/ytdl-mp3/commit/a25cb58aef3d1d9825d528aedbcd5a837bf26415)) 8 | 9 | ## [5.2.1](https://github.com/joshunrau/ytdl-mp3/compare/v5.2.0...v5.2.1) (2025-02-07) 10 | 11 | 12 | ### Bug Fixes 13 | 14 | * useless logs ([65df057](https://github.com/joshunrau/ytdl-mp3/commit/65df057cbe14aa6661aad15f41ee018fd4f30309)) 15 | 16 | ## [5.2.0](https://github.com/joshunrau/ytdl-mp3/compare/v5.1.0...v5.2.0) (2024-12-19) 17 | 18 | 19 | ### Features 20 | 21 | * included year and track number, added support for custom itunes search strings ([6fc57d0](https://github.com/joshunrau/ytdl-mp3/commit/6fc57d02cdff6af67920a6750eb8fa24d55aa66b)) 22 | 23 | 24 | ### Bug Fixes 25 | 26 | * removed unused import ([e45dd80](https://github.com/joshunrau/ytdl-mp3/commit/e45dd80857a556508e019fdcc6ed97d08aa94599)) 27 | 28 | ## [5.1.0](https://github.com/joshunrau/ytdl-mp3/compare/v5.0.0...v5.1.0) (2024-12-16) 29 | 30 | 31 | ### Features 32 | 33 | * included genre and album ([b19a51c](https://github.com/joshunrau/ytdl-mp3/commit/b19a51c838893b83395eaf6529157be1df28ee7f)) 34 | 35 | ## [5.0.0](https://github.com/joshunrau/ytdl-mp3/compare/v4.0.2...v5.0.0) (2024-08-25) 36 | 37 | 38 | ### ⚠ BREAKING CHANGES 39 | 40 | * the main export has been removed since the version was incorrect 41 | 42 | ### Code Refactoring 43 | 44 | * move main into bin ([fa6144c](https://github.com/joshunrau/ytdl-mp3/commit/fa6144c625f6e8d28f4e493fc55708000e5fb79c)) 45 | 46 | ## [4.0.2](https://github.com/joshunrau/ytdl-mp3/compare/v4.0.1...v4.0.2) (2024-08-25) 47 | 48 | 49 | ### Bug Fixes 50 | 51 | * issue where 403 error occurs ([a515cfe](https://github.com/joshunrau/ytdl-mp3/commit/a515cfe3af7f75271fdd5347f5481c9be367ae03)) 52 | 53 | ## [4.0.1](https://github.com/joshunrau/ytdl-mp3/compare/v4.0.0...v4.0.1) (2024-07-29) 54 | 55 | 56 | ### Bug Fixes 57 | 58 | * issue where cli side effect prevents import as cjs module ([85d9952](https://github.com/joshunrau/ytdl-mp3/commit/85d99525761a082cc7ba4487688d33cdacd8f853)) 59 | 60 | ## [4.0.0](https://github.com/joshunrau/ytdl-mp3/compare/v3.4.1...v4.0.0) (2024-06-19) 61 | 62 | 63 | ### ⚠ BREAKING CHANGES 64 | 65 | * set minimum node version to 20 66 | 67 | ### Bug Fixes 68 | 69 | * handle nested brackets in removeParenthesizedText ([6d3871b](https://github.com/joshunrau/ytdl-mp3/commit/6d3871bf278e1f3dc6bcf1e60fea92ae9d18108b)) 70 | 71 | 72 | ### Miscellaneous Chores 73 | 74 | * set minimum node version to 20 ([9e0996a](https://github.com/joshunrau/ytdl-mp3/commit/9e0996a41a5cb6c6bad953b5eb142bab9af446e6)) 75 | 76 | ## [3.4.1] - 2023-09-14 77 | 78 | ### Changed 79 | - Updated dependencies 80 | 81 | ## [3.1.0] - 2023-05-18 82 | 83 | ### Added 84 | - Log search term to stdout 85 | - Throw if output file exists 86 | - Add custom `YtdlMp3Error` exception 87 | - Catch `YtdlMp3Error` with nice formatting for cli users 88 | 89 | ## [3.0.1] - 2023-05-13 90 | 91 | ### Changed 92 | - Updated dependencies 93 | 94 | ### Removed 95 | - Unused, broken test from v2 96 | 97 | ## [3.0.0] - 2023-04-09 98 | 99 | Major refactoring of the entire codebase. Breaking change for library use, but not for CLI. 100 | 101 | ### Added 102 | - ESM Support 103 | - Downloader 104 | - FormatConverter 105 | - SongTagsSearch 106 | 107 | ### Removed 108 | - convertVideoToAudio 109 | - downloadSong 110 | - downloadVideo 111 | - extractSongTags 112 | - fetchAlbumArt 113 | - fetchSearchResults 114 | - getFilepaths 115 | - verifySearchResult 116 | -------------------------------------------------------------------------------- /src/__tests__/Downloader.test.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import path from 'path'; 3 | import { PassThrough } from 'stream'; 4 | 5 | import ytdl from '@distube/ytdl-core'; 6 | import NodeID3 from 'node-id3'; 7 | import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; 8 | import type { Mock } from 'vitest'; 9 | 10 | import { Downloader } from '../Downloader'; 11 | import { FormatConverter } from '../FormatConverter'; 12 | import { SongTagsSearch } from '../SongTagsSearch'; 13 | import * as utils from '../utils'; 14 | 15 | const { YtdlMp3Error } = utils; 16 | 17 | vi.mock('node-id3', () => ({ default: { write: vi.fn() } })); 18 | vi.mock('../SongTagsSearch', () => ({ SongTagsSearch: vi.fn() })); 19 | 20 | describe('Downloader', () => { 21 | const outputDir = path.resolve(import.meta.dirname, '__output__'); 22 | const url = 'https://www.youtube.com/watch?v=7jgnv0xCv-k'; 23 | 24 | beforeAll(() => { 25 | fs.mkdirSync(outputDir); 26 | }); 27 | 28 | afterAll(() => { 29 | fs.rmSync(outputDir, { force: true, recursive: true }); 30 | }); 31 | 32 | describe('downloadSong', () => { 33 | let downloadStream: PassThrough; 34 | let formatConverter: { videoToAudio: Mock }; 35 | 36 | beforeEach(() => { 37 | downloadStream = new PassThrough(); 38 | formatConverter = { videoToAudio: vi.fn() }; 39 | vi.spyOn(ytdl, 'getInfo').mockResolvedValue({ videoDetails: { title: '' } } as any); 40 | vi.spyOn(ytdl, 'downloadFromInfo').mockReturnValue(downloadStream as any); 41 | vi.spyOn(FormatConverter.prototype, 'videoToAudio').mockImplementation(formatConverter.videoToAudio); 42 | }); 43 | 44 | it('should throw if the output directory does not exist', async () => { 45 | const downloader = new Downloader({ getTags: false, outputDir }); 46 | vi.spyOn(utils, 'isDirectory').mockReturnValueOnce(false); 47 | await expect(downloader.downloadSong(url)).rejects.toBeInstanceOf(YtdlMp3Error); 48 | }); 49 | it('should throw a YtdlMp3Error if ytdl.getInfo rejects', async () => { 50 | const downloader = new Downloader({ getTags: false, outputDir }); 51 | vi.spyOn(ytdl, 'getInfo').mockRejectedValueOnce(new Error()); 52 | await expect(() => downloader.downloadSong(url)).rejects.toBeInstanceOf(YtdlMp3Error); 53 | }); 54 | it('should call ytdl.getInfo', async () => { 55 | const downloader = new Downloader({ getTags: false, outputDir }); 56 | const promise = downloader.downloadSong(url); 57 | expect(ytdl.getInfo).toHaveBeenCalledOnce(); 58 | downloadStream.emit('data', Buffer.from([1, 2, 3])); 59 | downloadStream.end(); 60 | await promise; 61 | }); 62 | it('should call NodeID3 with the return value of songTagsSearch.search, if getTags is set to true', async () => { 63 | const id = crypto.randomUUID(); 64 | const search = vi.fn(() => id); 65 | vi.mocked(SongTagsSearch).mockImplementationOnce(() => ({ search }) as any); 66 | const downloader = new Downloader({ getTags: true, outputDir }); 67 | const promise = downloader.downloadSong(url); 68 | downloadStream.end(); 69 | await promise; 70 | expect(search).toHaveBeenCalledOnce(); 71 | expect(NodeID3.write).toHaveBeenCalledOnce(); 72 | expect(NodeID3.write).toBeCalledWith(id, expect.any(String)); 73 | }); 74 | it('should return the downloader information', async () => { 75 | const downloader = new Downloader({ getTags: false, outputDir }); 76 | const promise = downloader.downloadSong(url); 77 | downloadStream.end(); 78 | await expect(promise).resolves.toMatchObject({ 79 | outputFile: expect.any(String) 80 | }); 81 | }); 82 | }); 83 | }); 84 | -------------------------------------------------------------------------------- /src/SongTagsSearch.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import type { AxiosError } from 'axios'; 3 | 4 | import { removeParenthesizedText, userInput, YtdlMp3Error } from './utils'; 5 | 6 | export type SearchResult = { 7 | artistName: string; 8 | artworkUrl100: string; 9 | collectionName: string; 10 | primaryGenreName: string; 11 | releaseDate: string; 12 | trackName: string; 13 | trackNumber: number; 14 | }; 15 | 16 | export type SearchData = { 17 | resultCount: number; 18 | results: SearchResult[]; 19 | }; 20 | 21 | export type AlbumArt = { 22 | description: string; 23 | imageBuffer: Buffer; 24 | mime: string; 25 | type: number; 26 | }; 27 | 28 | export type SongTags = { 29 | album: string; 30 | APIC: AlbumArt; 31 | artist: string; 32 | genre: string; 33 | title: string; 34 | TRCK: number; 35 | year: string; 36 | }; 37 | 38 | export class SongTagsSearch { 39 | private searchTerm: string; 40 | private url: URL; 41 | 42 | constructor(searchTerm: string) { 43 | this.searchTerm = removeParenthesizedText(searchTerm); 44 | this.url = new URL('https://itunes.apple.com/search?'); 45 | this.url.searchParams.set('media', 'music'); 46 | this.url.searchParams.set('term', this.searchTerm); 47 | } 48 | 49 | async search(verify = false): Promise { 50 | console.log(`Attempting to query iTunes API with the following search term: ${this.searchTerm}`); 51 | const searchResults = await this.fetchResults(); 52 | const result = verify ? await this.getVerifiedResult(searchResults) : searchResults[0]!; 53 | const artworkUrl = result.artworkUrl100.replace('100x100bb.jpg', '600x600bb.jpg'); 54 | const albumArt = await this.fetchAlbumArt(artworkUrl); 55 | return { 56 | album: result.collectionName, 57 | APIC: { 58 | description: 'Album Art', 59 | imageBuffer: albumArt, 60 | mime: 'image/jpeg', 61 | type: 3 62 | }, 63 | artist: result.artistName, 64 | genre: result.primaryGenreName, 65 | title: result.trackName, 66 | TRCK: result.trackNumber, 67 | year: result.releaseDate.substring(0, 4) 68 | }; 69 | } 70 | 71 | private async fetchAlbumArt(url: string): Promise { 72 | return axios 73 | .get(url, { responseType: 'arraybuffer' }) 74 | .then((response) => Buffer.from(response.data as string, 'binary')) 75 | .catch(() => { 76 | throw new YtdlMp3Error('Failed to fetch album art from endpoint: ' + url); 77 | }); 78 | } 79 | 80 | private async fetchResults(): Promise { 81 | const response = await axios.get(this.url.href).catch((error: AxiosError) => { 82 | if (error.response?.status) { 83 | throw new YtdlMp3Error(`Call to iTunes API returned status code ${error.response.status}`); 84 | } 85 | throw new YtdlMp3Error('Call to iTunes API failed and did not return a status'); 86 | }); 87 | 88 | if (response.data.resultCount === 0) { 89 | throw new YtdlMp3Error('Call to iTunes API did not return any results'); 90 | } 91 | 92 | return response.data.results; 93 | } 94 | 95 | private async getVerifiedResult(searchResults: SearchResult[]): Promise { 96 | for (const result of searchResults) { 97 | console.log('The following tags were extracted from iTunes:'); 98 | console.log('Title: ' + result.trackName); 99 | console.log('Artist: ' + result.artistName); 100 | console.log('Album: ' + result.collectionName); 101 | console.log('Genre: ' + result.primaryGenreName); 102 | 103 | const validResponses = ['Y', 'YES', 'N', 'NO']; 104 | let userSelection = (await userInput('Please verify (Y/N): ')).toUpperCase(); 105 | while (!validResponses.includes(userSelection)) { 106 | console.error('Invalid selection, try again!'); 107 | userSelection = (await userInput('Please verify (Y/N): ')).toUpperCase(); 108 | } 109 | if (userSelection === 'Y' || userSelection === 'YES') { 110 | return result; 111 | } 112 | } 113 | throw new YtdlMp3Error('End of results'); 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /src/Downloader.ts: -------------------------------------------------------------------------------- 1 | import os from 'os'; 2 | import path from 'path'; 3 | 4 | import ytdl from '@distube/ytdl-core'; 5 | import type { videoInfo as VideoInfo } from '@distube/ytdl-core'; 6 | import NodeID3 from 'node-id3'; 7 | 8 | import { FormatConverter } from './FormatConverter'; 9 | import { SongTagsSearch } from './SongTagsSearch'; 10 | import { isDirectory, removeParenthesizedText, YtdlMp3Error } from './utils'; 11 | 12 | import type { SongTags } from './SongTagsSearch'; 13 | 14 | type DownloaderOptions = { 15 | customSearchTerm?: null | string; 16 | getTags?: boolean; 17 | outputDir?: string; 18 | silentMode?: boolean; 19 | verifyTags?: boolean; 20 | }; 21 | 22 | type DownloaderItemInformation = { 23 | album: null | string; 24 | artist: null | string; 25 | genre: null | string; 26 | outputFile: string; 27 | trackNo: null | number; 28 | year: null | string; 29 | }; 30 | 31 | export class Downloader { 32 | static defaultDownloadsDir = path.join(os.homedir(), 'Downloads'); 33 | 34 | customSearchTerm: null | string; 35 | getTags: boolean; 36 | outputDir: string; 37 | silentMode: boolean; 38 | verifyTags: boolean; 39 | 40 | constructor({ customSearchTerm, getTags, outputDir, silentMode, verifyTags }: DownloaderOptions) { 41 | this.outputDir = outputDir ?? Downloader.defaultDownloadsDir; 42 | this.getTags = Boolean(getTags); 43 | this.silentMode = Boolean(silentMode); 44 | this.verifyTags = Boolean(verifyTags); 45 | this.customSearchTerm = customSearchTerm ?? null; 46 | } 47 | 48 | async downloadSong(url: string): Promise { 49 | if (!isDirectory(this.outputDir)) { 50 | throw new YtdlMp3Error(`Not a directory: ${this.outputDir}`); 51 | } 52 | const videoInfo = await ytdl.getInfo(url).catch((error) => { 53 | throw new YtdlMp3Error(`Failed to fetch info for video with URL: ${url}`, { 54 | cause: error 55 | }); 56 | }); 57 | 58 | const formatConverter = new FormatConverter(); 59 | const songTagsSearch = new SongTagsSearch( 60 | this.customSearchTerm 61 | ? this.customSearchTerm 62 | .replaceAll('{title}', videoInfo.videoDetails.title) 63 | .replaceAll('{uploader}', videoInfo.videoDetails.author.name) 64 | : videoInfo.videoDetails.title 65 | ); 66 | 67 | const outputFile = this.getOutputFile(videoInfo.videoDetails.title); 68 | const videoData = await this.downloadVideo(videoInfo).catch((error) => { 69 | throw new YtdlMp3Error('Failed to download video', { 70 | cause: error 71 | }); 72 | }); 73 | 74 | formatConverter.videoToAudio(videoData, outputFile); 75 | let songTags: null | SongTags = null; 76 | if (this.getTags) { 77 | songTags = await songTagsSearch.search(this.verifyTags); 78 | NodeID3.write(songTags, outputFile); 79 | } 80 | 81 | if (!this.silentMode) { 82 | console.log(`Done! Output file: ${outputFile}`); 83 | } 84 | 85 | return { 86 | album: songTags?.album ?? null, 87 | artist: songTags?.artist ?? null, 88 | genre: songTags?.genre ?? null, 89 | outputFile, 90 | trackNo: songTags?.TRCK ?? null, 91 | year: songTags?.year ?? null 92 | }; 93 | } 94 | 95 | /** Returns the content from the video as a buffer */ 96 | private async downloadVideo(videoInfo: VideoInfo): Promise { 97 | const buffers: Buffer[] = []; 98 | const stream = ytdl.downloadFromInfo(videoInfo, { quality: 'highestaudio' }); 99 | return new Promise((resolve, reject) => { 100 | stream.on('data', (chunk: Buffer) => { 101 | buffers.push(chunk); 102 | }); 103 | stream.on('end', () => { 104 | resolve(Buffer.concat(buffers)); 105 | }); 106 | stream.on('error', (err) => { 107 | reject(err); 108 | }); 109 | }); 110 | } 111 | 112 | /** Returns the absolute path to the audio file to be downloaded */ 113 | private getOutputFile(videoTitle: string): string { 114 | const baseFileName = removeParenthesizedText(videoTitle) 115 | .replace(/[^a-z0-9]/gi, '_') 116 | .split('_') 117 | .filter((element) => element) 118 | .join('_') 119 | .toLowerCase(); 120 | return path.join(this.outputDir, baseFileName + '.mp3'); 121 | } 122 | } 123 | 124 | export type { DownloaderOptions }; 125 | --------------------------------------------------------------------------------