diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..38311ad --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Unix lines endings in bash script files +*.sh text eol=lf \ No newline at end of file diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..f101f23 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,43 @@ +# This workflow will run tests using node and then publish a package to GitHub Packages when a release is created +# For more information see: https://docs.github.com/en/actions/publishing-packages/publishing-nodejs-packages + +name: release + +on: + push: + tags: + - 'v*' + - '!v*rc*' + - '!v*beta*' + - '!v*alpha*' + +jobs: + publish-npm: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v3 + with: + node-version: 18.18.0 + registry-url: https://registry.npmjs.org/ + + - name: Install dependencies + run: npm install + + - name: Run build + run: npm run build + + - name: Prepare commit message + uses: 'marvinpinto/action-automatic-releases@latest' + with: + repo_token: '${{ secrets.GITHUB_TOKEN }}' + prerelease: false + files: LICENSE + + - name: Publish dist to NPM + run: npm publish --access public + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..975ed8e --- /dev/null +++ b/.gitignore @@ -0,0 +1,41 @@ +# Build +dist/ + +# Cache +.cache + +# Lock Files +package-lock.json +yarn.lock + +# VisualStudioCode template +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +!.vscode/*.code-snippets + +# Local History for Visual Studio Code +.history/ + +# IntelliJ project files +.idea + +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* +.pnpm-debug.log* + +# Dependency directories +node_modules/ + +# Engineering directories +# lib/ + +# Coverage directories +coverage/ \ No newline at end of file diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100644 index 0000000..1cee9b6 --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1,4 @@ +#!/bin/sh +# .husky/pre-commit + +npm run lint-staged diff --git a/.husky/pre-push b/.husky/pre-push new file mode 100644 index 0000000..30fc9b2 --- /dev/null +++ b/.husky/pre-push @@ -0,0 +1,7 @@ +#!/bin/sh +# .husky/pre-push + +# Detected changes in the src or bin directory, running checks... +if git diff --cached --name-only | grep -Eq '^(src|bin)/'; then + npm run build +fi diff --git a/.lintstagedrc.yml b/.lintstagedrc.yml new file mode 100644 index 0000000..0008d6e --- /dev/null +++ b/.lintstagedrc.yml @@ -0,0 +1,2 @@ +"src/**/*.ts": + - "biome check src bin --write" \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..3a384a6 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Zhang Zixin + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..d627fdb --- /dev/null +++ b/README.md @@ -0,0 +1,102 @@ +# 网易云音乐下载 + +一个简单的 Node.js CLI 库,用来下载网易云音乐源的 mp3 文件。 + +## 📦 安装 + +1. 确保安装过 Node 程序。 + + > 官方提供的简易安装包: [下载链接 (约 30 MB)][node-url] + +2. 打开任意终端(cmd/bash/powershell/...)安装当前包。 + + ```bash + npm i -g netease-cloud-music-download + ``` + +## ⚙️ 使用 + +- 使用 ID + + ```bash + netease-dl 25159744 + ``` + + ``` + 下载进度 [========================================] 100% | 433467/433467 KB + ✔ 下载完成: C:\Users\User\Downloads\Hello Makka Pakka! - Andrew Davenport.mp3 + ``` + +- 使用分享链接 + + ```bash + netease-dl "https://music.xxx.com/song?id=25159744" + ``` + + ``` + 下载进度 [========================================] 100% | 433467/433467 KB + ✔ 下载完成: C:\Users\User\Downloads\Hello Makka Pakka! - Andrew Davenport.mp3 + ``` + +- 使用剪贴板 + + ```bash + netease-dl + ``` + + ``` + 下载进度 [========================================] 100% | 433467/433467 KB + ✔ 下载完成: C:\Users\User\Downloads\Hello Makka Pakka! - Andrew Davenport.mp3 + ``` + +- 指定下载目录 (`.`表示当前目录) + + ```bash + netease-dl -o . + ``` + + ``` + 下载进度 [========================================] 100% | 433467/433467 KB + ✔ 下载完成: Hello Makka Pakka! - Andrew Davenport.mp3 + ``` + +- 当下载目录同名文件存在时,覆盖现有文件 + + ```bash + netease-dl -r + ``` + + ``` + 下载进度 [========================================] 100% | 433467/433467 KB + ✔ 下载完成: C:\Users\User\Downloads\Hello Makka Pakka! - Andrew Davenport.mp3 + ``` + +## ❓ Q & A + +### \> 默认文件会下载到哪里 + +未指定 -o 参数时,默认下载到当前用户默认的 Downloads 文件夹,即浏览器下载文件目录。 + +### \> 文件命名规则是什么 + +单曲名 - 歌手名1\[, 歌手名2, ...\](若有多个).mp3 + +### \> 如何获取分享链接 + +在客户端中点击一首歌曲的分享按钮 > 点击复制链接。 + +或者直接复制单曲在浏览器中的链接。 + +## 🤝 贡献 + +欢迎通过 Pull Requests 或 [Issues][issues-url] 来贡献你的想法和代码。 + +## 📄 许可 + +本项目采用 MIT 许可证。详情请见 [LICENSE][license-url] 文件。 + +[node-url]: https://nodejs.org/zh-cn/download/prebuilt-installer + +[issues-url]: https://github.com/gengark/netease-cloud-music-download/issues + +[license-url]: LICENSE diff --git a/bin/cli.ts b/bin/cli.ts new file mode 100644 index 0000000..6273e87 --- /dev/null +++ b/bin/cli.ts @@ -0,0 +1,50 @@ +#!/usr/bin/env node +import { readFileSync } from 'node:fs'; +import process from 'node:process'; +import updateNotifier from 'update-notifier'; +import yargs from 'yargs'; +import { hideBin } from 'yargs/helpers'; +import main from '../src'; + +process.on('SIGINT', () => { + process.exit(0); +}); + +const pkg = JSON.parse( + readFileSync(new URL('../package.json', import.meta.url)).toString('utf8'), +); + +updateNotifier({ pkg }).notify({ isGlobal: true }); + + +const palette = (code: number) => (text: string) => + `\u001B[${code}m${text}\u001B[39m`; +const grey = palette(90); +const yellow = palette(33); + +main( + yargs(hideBin(process.argv)) + .scriptName('netease-dl') + .usage('$0 [options] ') + .options('output', { + alias: 'o', + type: 'string', + desc: '输出路径 (目录/文件名.mp3)', + }) + .options('rewrite', { + alias: 'r', + type: 'boolean', + desc: '覆盖现有的同名文件 (若存在)', + }) + .example(yellow('netease-dl'), '使用剪贴板中的单曲ID / 链接') + .example(yellow('netease-dl 25159744'), '指定单曲ID') + .example(yellow('netease-dl "https://music.163.com/song?id=25159744"'), '指定单曲链接') + .example(grey('-------'), '') + .example(yellow('netease-dl'), '下载到当前用户默认的 Downloads 目录') + .example(yellow('netease-dl -o .'), '下载到当前目录中') + .alias({ + v: 'version', + h: 'help', + }) + .parseSync(), +).catch(console.error); diff --git a/biome.json b/biome.json new file mode 100644 index 0000000..6e55e5a --- /dev/null +++ b/biome.json @@ -0,0 +1,44 @@ +{ + "$schema": "./node_modules/@biomejs/biome/configuration_schema.json", + "vcs": { + "enabled": false, + "useIgnoreFile": false, + "clientKind": "git", + "defaultBranch": "master" + }, + "files": { + "ignore": ["dist", "src/locale"], + "ignoreUnknown": false + }, + "formatter": { + "enabled": true, + "indentWidth": 4, + "indentStyle": "space", + "lineWidth": 80, + "lineEnding": "lf" + }, + "organizeImports": { + "enabled": true + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true + } + }, + "javascript": { + "formatter": { + "enabled": true, + "indentWidth": 4, + "indentStyle": "space", + "lineWidth": 80, + "lineEnding": "lf", + "quoteStyle": "single", + "jsxQuoteStyle": "double", + "trailingCommas": "all", + "semicolons": "always", + "quoteProperties": "asNeeded", + "arrowParentheses": "always" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..c362744 --- /dev/null +++ b/package.json @@ -0,0 +1,61 @@ +{ + "name": "netease-cloud-music-download", + "description": "A simple Node.js CLI library that helps you to download mp3 file for NetEase Cloud Music source", + "version": "0.1.0", + "type": "module", + "main": "./dist/cli.js", + "types": "./dist/cli.d.ts", + "files": [ + "dist" + ], + "bin": { + "netease-dl": "./dist/cli.js" + }, + "scripts": { + "dev": "ts-node bin/cli.ts", + "prepare": "husky", + "lint": "biome check src bin --write", + "lint-staged": "lint-staged", + "build": "tsup", + "watch": "tsup --watch" + }, + "author": { + "name": "gengarr", + "email": "97z4moon@gmail.com", + "url": "https://github.com/gengark" + }, + "homepage": "https://github.com/gengark/netease-cloud-music-download#readme", + "repository": "git@github.com:gengark/netease-cloud-music-download.git", + "bugs": "https://github.com/gengark/netease-cloud-music-download/issue", + "keywords": [ + "netease", + "music", + "mp3", + "download", + "网易云音乐" + ], + "engines": { + "node": ">=18" + }, + "license": "MIT", + "devDependencies": { + "@biomejs/biome": "^1.9.4", + "@types/cli-progress": "^3.11.6", + "@types/node": "^22.10.2", + "@types/update-notifier": "^6.0.8", + "@types/yargs": "^17.0.33", + "husky": "^9.1.7", + "lint-staged": "^15.2.11", + "ts-node": "^10.9.2", + "tsup": "^8.3.5", + "typescript": "^5.7.2" + }, + "dependencies": { + "cli-progress": "^3.12.0", + "clipboardy": "^4.0.0", + "open": "^10.1.0", + "ora": "^8.1.1", + "update-notifier": "^7.3.1", + "yargs": "^17.7.2" + } +} diff --git a/src/access-write.ts b/src/access-write.ts new file mode 100644 index 0000000..42c74d1 --- /dev/null +++ b/src/access-write.ts @@ -0,0 +1,16 @@ +import { constants, accessSync, statSync } from 'node:fs'; +import { dirname } from 'node:path'; + +function accessWrite(filepath: string) { + const dirPath = dirname(filepath); + const stats = statSync(dirname(dirPath)); + if (!stats.isDirectory()) return '目录不存在'; + + try { + accessSync(dirPath, constants.W_OK); + } catch { + return '无可写权限'; + } +} + +export default accessWrite; diff --git a/src/constants.ts b/src/constants.ts new file mode 100644 index 0000000..6fc41d6 --- /dev/null +++ b/src/constants.ts @@ -0,0 +1,5 @@ +export const HTTPS_HOST_PATH = 'https://music.163.com/song'; + +export const HTTP_HOST_PATH = 'http://music.163.com/song'; + +export const SOURCE_PATH = '/media/outer/url'; diff --git a/src/download-file.ts b/src/download-file.ts new file mode 100644 index 0000000..d1a387d --- /dev/null +++ b/src/download-file.ts @@ -0,0 +1,62 @@ +import fs from 'node:fs'; +import http from 'node:http'; +import cliProgress from 'cli-progress'; +import type { Ora } from 'ora'; + +async function downloadFile(url: string, output: string, spinner: Ora) { + const parsedUrl = new URL(url); + + try { + http.get(parsedUrl, (response) => { + if (response.statusCode === 302 || response.statusCode === 301) { + const redirectUrl = response.headers.location; + if (!redirectUrl) { + spinner.fail('所请求的资源位置已更改,无法正确重定向'); + } else { + downloadFile(redirectUrl, output, spinner); + } + } else if (response.statusCode === 200) { + const fileStream = fs.createWriteStream(output); + + const contentLength = response.headers['content-length']; + const totalSize = Number.parseInt(contentLength || '0', 10); + const progressBar = new cliProgress.SingleBar( + { + format: '下载进度 [{bar}] {percentage}% | {value}/{total} KB', + hideCursor: true, + }, + cliProgress.Presets.legacy, + ); + + progressBar.start(totalSize, 0); + + let downloaded = 0; + + response.on('data', (chunk) => { + downloaded += chunk.length; + progressBar.update(downloaded); + }); + + response.pipe(fileStream); + + fileStream.on('finish', () => { + progressBar.stop(); + spinner.succeed(`下载完成: ${output}`); + }); + + fileStream.on('error', (err) => { + progressBar.stop(); + spinner.fail(`下载失败: ${err}`); + }); + } else { + spinner.fail(`请求失败,状态码: ${response.statusCode}`); + } + }).on('error', (err) => { + spinner.fail(`请求错误: ${err}`); + }); + } catch { + spinner.fail('该单曲为会员单曲/付费单曲/没有版权') + } +} + +export default downloadFile; diff --git a/src/get-clipboard-content.ts b/src/get-clipboard-content.ts new file mode 100644 index 0000000..a74bb72 --- /dev/null +++ b/src/get-clipboard-content.ts @@ -0,0 +1,12 @@ +import clipboard from 'clipboardy'; + +async function getClipboardContent(): Promise<[undefined, string] | [string]> { + try { + const clipboardContent = await clipboard.read(); + return [undefined, clipboardContent]; + } catch { + return ['剪贴板非文字内容']; + } +} + +export default getClipboardContent; diff --git a/src/get-download-directory.ts b/src/get-download-directory.ts new file mode 100644 index 0000000..92aa0c6 --- /dev/null +++ b/src/get-download-directory.ts @@ -0,0 +1,23 @@ +import { homedir } from 'node:os'; +import { join } from 'node:path'; +import accessWrite from './access-write'; +import isExist from './is-exist'; + +const HOME_DIR = homedir(); +const DL_DIR = join(HOME_DIR, 'Downloads'); + +function getDownloadDirectory( + filename: string, + directory?: string, + rewrite = false, +) { + const dest = join(directory || DL_DIR, filename); + if (!rewrite && isExist(dest)) return ['文件已存在']; + + const err = accessWrite(dest); + if (err) return [`${err}: ${dest}`]; + + return [undefined, dest]; +} + +export default getDownloadDirectory; diff --git a/src/get-info.ts b/src/get-info.ts new file mode 100644 index 0000000..3485ba4 --- /dev/null +++ b/src/get-info.ts @@ -0,0 +1,42 @@ +interface ArtistInfo { + id: number; + name: string; +} + +interface SongInfo { + id: number; + name: string; + artists: ArtistInfo[]; +} + +const normalizeName = (name?: string) => + name ? name.replace(/[<>:"/\\|?*]/g, '').trim() : undefined; + +async function getInfo(id: string) { + const result = { + song: '未知单曲', + artists: '未知作者', + }; + + try { + const response = await fetch( + `https://music.163.com/api/song/detail?ids=[${id}]`, + ); + if (!response.ok) return result; + + const data: { songs: SongInfo[] } | undefined = await response.json(); + const songData = data?.songs?.[0]; + result.song = normalizeName(songData?.name) ?? '未知单曲'; + result.artists = normalizeName( + (songData?.artists ?? [{ name: '未知作者' }]) + .map((artist) => artist.name) + .join(', '), + ) as string; + + return result; + } catch (error) { + return result; + } +} + +export default getInfo; diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..6486fba --- /dev/null +++ b/src/index.ts @@ -0,0 +1,46 @@ +import ora from 'ora'; +import { HTTP_HOST_PATH, SOURCE_PATH } from './constants'; +import downloadFile from './download-file'; +import getDownloadDirectory from './get-download-directory'; +import getInfo from './get-info'; +import normalizeInput from './normalize-input'; + +export interface Options { + _?: (string | number)[]; + output?: string; + rewrite?: boolean; +} + +async function main({ _: input, output, rewrite }: Options = {}) { + const spinner = ora({ color: 'cyan' }); + + const [err, id] = await normalizeInput(input?.[0]?.toString()); + if (err) { + spinner.fail(err); + return; + } + if (!id) { + spinner.fail(`无效的单曲ID: ${id}`); + return; + } + + const info = await getInfo(id); + const [error, destPath] = getDownloadDirectory( + `${info.song} - ${info.artists}.mp3`, + output, + rewrite, + ); + if (error) { + spinner.fail(error); + return; + } + if (!destPath) { + spinner.fail(`无效的目录: ${destPath}`); + return; + } + + const sourceUrl = `${HTTP_HOST_PATH}/${SOURCE_PATH}?id=${id}`; + await downloadFile(sourceUrl, destPath, spinner); +} + +export default main; diff --git a/src/is-exist.ts b/src/is-exist.ts new file mode 100644 index 0000000..82e38ab --- /dev/null +++ b/src/is-exist.ts @@ -0,0 +1,12 @@ +import { constants, accessSync } from 'node:fs'; + +function isExist(filepath: string) { + try { + accessSync(filepath, constants.F_OK); + return true; + } catch { + return false; + } +} + +export default isExist; diff --git a/src/normalize-input.ts b/src/normalize-input.ts new file mode 100644 index 0000000..33e7d12 --- /dev/null +++ b/src/normalize-input.ts @@ -0,0 +1,30 @@ +import { HTTPS_HOST_PATH } from './constants'; +import getClipboardContent from './get-clipboard-content'; + +async function normalizeInput( + input?: string, +): Promise<[undefined, string] | [string]> { + let result = input; + if (!result) { + const [err, content] = await getClipboardContent(); + if (err) return [err]; + if (!content) return ['剪贴板内容为空']; + result = content; + } + + const numericInput = Number(result); + if (!Number.isNaN(numericInput) && !!numericInput) + return [undefined, `${numericInput}`]; + + result = result?.replace(/\/#\//, '/'); + if (!result.startsWith(HTTPS_HOST_PATH)) + return [`无效的单曲ID/分享链接: ${JSON.stringify(result)}`]; + + const url = new URL(result); + const id = url.searchParams.get('id'); + if (!id) return [`链接缺少ID: "${url}"`]; + + return [undefined, id]; +} + +export default normalizeInput; diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..8c2f13d --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "Bundler", + "outDir": "./dist", + "rootDir": ".", + "typeRoots": ["./node_modules/@types", "types"], + "strict": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "allowSyntheticDefaultImports": true, + "forceConsistentCasingInFileNames": true, + "declaration": true, + "experimentalDecorators": true + }, + "ts-node": { + "experimentalSpecifierResolution": "node", + "esm": true, + "transpileOnly": true + }, + "include": ["src", "bin", "types"], + "exclude": ["node_modules", "dist", "src"] +} \ No newline at end of file diff --git a/tsup.config.ts b/tsup.config.ts new file mode 100644 index 0000000..7f7f52c --- /dev/null +++ b/tsup.config.ts @@ -0,0 +1,18 @@ +import { defineConfig } from 'tsup'; + +export default defineConfig((opts) => ({ + entry: ['bin/cli.ts'], + format: ['esm'], + outDir: 'dist', + target: ['esnext'], + bundle: true, + clean: !opts.watch, + minify: false, + treeshake: !opts.watch, + sourcemap: false, + splitting: false, + cjsInterop: true, + legacyOutput: false, + replaceNodeEnv: true, + dts: true, +}));