├── .eslintignore
├── .eslintrc
├── .github
└── workflows
│ ├── ci.yml
│ └── release.yml
├── .gitignore
├── .husky
└── pre-commit
├── .prettierignore
├── .prettierrc
├── LICENSE
├── README.md
├── api
├── index.js
└── search
│ ├── index.js
│ ├── services
│ └── migu.js
│ └── utils
│ └── index.js
├── bin
└── cli.js
├── package.json
├── pnpm-lock.yaml
├── public
├── index.css
├── music.png
└── music.svg
├── src
├── choose.ts
├── command.ts
├── download.ts
├── index.ts
├── names.ts
├── qrcode
│ └── index.ts
├── searchMusic.ts
├── services
│ ├── lyric
│ │ ├── index.ts
│ │ ├── kugou.ts
│ │ ├── kuwo.ts
│ │ ├── migu.ts
│ │ └── wangyi.ts
│ └── search
│ │ ├── index.ts
│ │ ├── kugou.ts
│ │ ├── kuwo.ts
│ │ ├── migu.ts
│ │ └── wangyi.ts
├── types.ts
└── utils
│ └── index.ts
├── template
└── index.html
├── test
├── download.test.js
├── helpers
│ └── readline.js
└── utils.test.js
├── tsconfig.json
├── tsup.config.ts
├── vercel.json
└── vitest.config.ts
/.eslintignore:
--------------------------------------------------------------------------------
1 | # Ignore artifacts:
2 | dist
3 |
4 | npm-debug.log
5 | bower_components
6 | node_modules
7 | yarn-error.log
8 | yarn.lock
9 | pnpm-lock.yaml
--------------------------------------------------------------------------------
/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | "parser": "@typescript-eslint/parser",
3 | "env": {
4 | "node": true,
5 | "es2021": true
6 | },
7 | "extends": [
8 | "plugin:@typescript-eslint/eslint-recommended",
9 | "plugin:@typescript-eslint/recommended",
10 | "prettier"
11 | ],
12 | "parserOptions": {
13 | "project": "tsconfig.json",
14 | "ecmaVersion": 12,
15 | "sourceType": "module"
16 | },
17 | "plugins": ["@typescript-eslint/eslint-plugin"],
18 | "rules": {
19 | "no-await-in-loop": "error",
20 | "prefer-const": "off",
21 | "@typescript-eslint/ban-ts-comment": "off",
22 | "@typescript-eslint/no-explicit-any": "off"
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/.github/workflows/ci.yml:
--------------------------------------------------------------------------------
1 | name: CI
2 |
3 | on:
4 | push:
5 | branches:
6 | - main
7 |
8 | pull_request:
9 | branches:
10 | - main
11 |
12 | jobs:
13 | lint:
14 | runs-on: ubuntu-latest
15 | steps:
16 | - uses: actions/checkout@v3
17 |
18 | - uses: actions/setup-node@v3
19 | with:
20 | node-version: 16.x
21 |
22 | - run: npm i -g pnpm
23 |
24 | - run: pnpm install
25 |
26 | - run: pnpm run lint
27 |
--------------------------------------------------------------------------------
/.github/workflows/release.yml:
--------------------------------------------------------------------------------
1 | name: Release npmjs package and generate github changelog
2 |
3 | on:
4 | push:
5 | tags:
6 | - 'v*'
7 |
8 | jobs:
9 | build:
10 | runs-on: ubuntu-latest
11 | steps:
12 | - uses: actions/checkout@v3
13 | with:
14 | fetch-depth: 0
15 |
16 | - uses: actions/setup-node@v3
17 | with:
18 | node-version: '16.x'
19 | registry-url: 'https://registry.npmjs.org'
20 |
21 | - env:
22 | GITHUB_TOKEN: ${{secrets.GIT_HUB_TOKEN}}
23 | run: npx changelogithub
24 |
25 | - run: npm i -g pnpm
26 |
27 | - run: pnpm install
28 |
29 | - run: pnpm build
30 |
31 | - env:
32 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
33 | run: npm publish
34 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .idea/
2 | node_modules/
3 | .vscode/
4 | .DS_Store
5 | dist
6 | .vercel
7 |
--------------------------------------------------------------------------------
/.husky/pre-commit:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 | . "$(dirname "$0")/_/husky.sh"
3 |
4 | npx lint-staged
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
1 | # Ignore artifacts:
2 | dist
3 |
4 | npm-debug.log
5 | bower_components
6 | node_modules
7 | yarn-error.log
8 | yarn.lock
9 | pnpm-lock.yaml
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "semi": false,
3 | "singleQuote": true,
4 | "javascript.format.insertSpaceBeforeFunctionParenthesis": true,
5 | "eslintIntegration": true,
6 | "htmlWhitespaceSensitivity": "ignore",
7 | "endOfLine": "auto",
8 | "printWidth": 100
9 | }
10 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2021 灵谦
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.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 | # Musicn
4 |
5 | 🎵 一个可播放及下载音乐的 Node.js 命令行工具
6 |
7 |

8 |
9 |
10 |
11 | ## 全局安装
12 |
13 | ```bash
14 | $ npm i musicn -g
15 | # or
16 | $ yarn global add musicn
17 | ```
18 |
19 | 容器形式部署安装可以参考:[musicn-container](https://github.com/wy580477/musicn-container)
20 |
21 | ## 使用
22 |
23 | ```bash
24 | $ musicn 周杰伦
25 | # or
26 | $ msc 周杰伦
27 | ```
28 |
29 | ### 帮助信息:
30 |
31 | ```bash
32 | $ msc --help
33 | # or
34 | $ msc -h
35 | ```
36 |
37 | ### 开启本地播放链接(手机可扫描二维码)下载及播放:
38 |
39 | ```bash
40 | $ msc --qrcode
41 | # or
42 | $ msc -q
43 | # or
44 | $ msc -q -P 3000 # 指定端口,-P为大写
45 | ```
46 | > 注意:使用手机扫描二维码时,电脑和手机两个设备必须连接到同一个 Wi-Fi;强烈推荐这种方式,既能听歌又能下载歌曲
47 |
48 |
49 |

50 |
51 |
52 | 也可以部署到自己的服务器,具体方法如下:
53 |
54 | ```shell
55 | git clone https://github.com/zonemeen/musicn.git
56 | cd musicn
57 | npm install
58 | npm run build
59 | node ./bin/cli.js -q
60 | # or
61 | pm2 start ./bin/cli.js --name music-app -- -q
62 | ```
63 |
64 | ### 开启本地播放链接是否自动打开浏览器:
65 |
66 | ```bash
67 | $ msc -q --open
68 | # or
69 | $ msc -q -o
70 | ```
71 |
72 | ### 搜索的页码数(默认是第1页):
73 |
74 | ```bash
75 | $ msc 周杰伦 --number 2
76 | # or
77 | $ msc 周杰伦 -n 2
78 | # or
79 | $ msc -n 2 周杰伦
80 | ```
81 |
82 | ### 搜索的歌曲数量(默认是20条):
83 |
84 | ```bash
85 | $ msc 周杰伦 --size 10 -w # wangyi的服务
86 | # or
87 | $ msc 周杰伦 -s 10 -w
88 | # or
89 | $ msc -s 10 -w 周杰伦
90 | ```
91 |
92 | > 注意:咪咕正常搜索因为api不支持,搜索时的自定义歌曲数量是无效的
93 |
94 | ### 咪咕服务下载(默认是这个服务):
95 |
96 | ```bash
97 | $ msc 周杰伦
98 | ```
99 |
100 | ### 网易云服务下载:
101 |
102 | ```bash
103 | $ msc 周杰伦 --wangyi
104 | # or
105 | $ msc 周杰伦 -w
106 | # or
107 | $ msc -w 周杰伦
108 | ```
109 |
110 | ### 酷狗服务下载:
111 |
112 | ```bash
113 | $ msc 周杰伦 --kugou
114 | # or
115 | $ msc 周杰伦 -g
116 | # or
117 | $ msc -g 周杰伦
118 | ```
119 |
120 | ### 根据歌单id下载:
121 |
122 | ```bash
123 | $ msc --songListId 206140403
124 | # or
125 | $ msc -i 206140403
126 | # or
127 | $ msc -i 206140403 -n 2
128 | ```
129 |
130 | ### 自定义下载路径(默认是当前路径):
131 |
132 | ```bash
133 | $ msc 周杰伦 --path ../music
134 | # or
135 | $ msc 周杰伦 -p ../music
136 | # or
137 | $ msc -p ../music 周杰伦
138 | ```
139 |
140 | > 开启本地播放链接时附带此参数,可通过 web 页面下载音乐至服务器对应 path
141 |
142 | ### 自定义base URL(默认为空):
143 |
144 | ```bash
145 | $ msc 周杰伦 -q --base musicn
146 | # or
147 | $ msc 周杰伦 -q -b musicn
148 | ```
149 |
150 | > 注意:只应用在开启本地播放链接时且首尾不能是 `/`,最终呈现的 URL `http://192.168.0.204:7478/musicn`
151 |
152 | ### 附带歌词下载(默认是不附带):
153 |
154 | ```bash
155 | $ msc 周杰伦 --lyric
156 | # or
157 | $ msc 周杰伦 -l
158 | # or
159 | $ msc -l 周杰伦
160 | ```
161 |
162 | > 开启本地播放链接时附带此参数,可通过 web 页面下载歌词至服务器对应 path(需结合 path 使用)
163 |
164 | ### 版本信息:
165 |
166 | ```bash
167 | $ msc --version
168 | # or
169 | $ msc -v
170 | ```
171 |
172 | ## 资源
173 |
174 | - 音乐来源: 咪咕、酷狗和网易云(API 是从公开的网络中获得)
175 |
176 | ## 说明
177 |
178 | 1. 暂时只支持咪咕、酷狗和网易云的服务(因一些特殊原因,其余平台暂时是不支持的,所有服务暂时也只支持普通mp3格式的下载及播放,且部分服务的会员专属歌曲下载暂时也不支持,后期会继续探索其余平台可用的音乐下载)
179 | 2. 在 `windows` 桌面端的 `git Bash` 中不支持上下切换选歌,问题是 `inquirer` 不兼容,建议使用其它终端工具
180 | 3. node version > 16
181 | 4. 此项目仅供个人学习研究,严禁用于商业用途
182 |
--------------------------------------------------------------------------------
/api/index.js:
--------------------------------------------------------------------------------
1 | const templateHtmlStr =
2 | '\n' +
3 | ' \n' +
4 | ' \n' +
5 | ' \n' +
6 | ' \n' +
7 | ' \n' +
8 | ' \n' +
9 | ' \n' +
10 | ' \n' +
11 | ' \n' +
29 | ' \n' +
30 | '\n' +
31 | ' \n' +
32 | ' \n' +
33 | '
\n' +
34 | ' \n' +
35 | '
\n' +
36 | '
\n' +
37 | '
\n' +
38 | ' \n' +
39 | ' \n' +
46 | ' \n' +
47 | '
\n' +
48 | '
\n' +
49 | ' \n' +
59 | ' \n' +
60 | ' {{record.songName}}\n' +
61 | ' \n' +
62 | ' \n' +
63 | ' \n' +
70 | ' \n' +
71 | ' \n' +
72 | ' \n' +
73 | ' \n' +
74 | '
\n' +
75 | ' \n' +
174 | ' \n' +
175 | '\n'
176 |
177 | export default async (req, res) => {
178 | res.send(templateHtmlStr)
179 | }
180 |
--------------------------------------------------------------------------------
/api/search/index.js:
--------------------------------------------------------------------------------
1 | import search from './services/migu.js'
2 |
3 | export default async (req, res) => {
4 | const { service, text, pageNum } = req.query
5 | const { searchSongs, totalSongCount } = await search[service]({
6 | text,
7 | pageNum,
8 | })
9 | res.send({ searchSongs, totalSongCount })
10 | }
11 |
--------------------------------------------------------------------------------
/api/search/services/migu.js:
--------------------------------------------------------------------------------
1 | import got from 'got'
2 | import { removePunctuation, joinSingersName } from '../utils/index.js'
3 |
4 | export default async ({ text, pageNum, songListId }) => {
5 | let searchSongs, totalSongCount
6 | if (songListId) {
7 | const songListSearchUrl = `https://app.c.nf.migu.cn/MIGUM3.0/v1.0/user/queryMusicListSongs.do?musicListId=${songListId}&pageNo=${pageNum}&pageSize=20`
8 | const { list, totalCount } = await got(songListSearchUrl).json()
9 | searchSongs = list
10 | totalSongCount = totalCount || undefined
11 | } else {
12 | const normalSearchUrl = `https://pd.musicapp.migu.cn/MIGUM3.0/v1.0/content/search_all.do?text=${encodeURIComponent(
13 | text
14 | )}&pageNo=${pageNum}&searchSwitch={song:1}`
15 | const { songResultData } = await got(normalSearchUrl).json()
16 | searchSongs = songResultData?.result || []
17 | totalSongCount = songResultData?.totalCount
18 | }
19 | const detailResults = await Promise.all(
20 | searchSongs.map(({ copyrightId }) => {
21 | const detailUrl = `https://c.musicapp.migu.cn/MIGUM2.0/v1.0/content/resourceinfo.do?copyrightId=${copyrightId}&resourceType=2`
22 | return got(detailUrl).json()
23 | })
24 | )
25 | searchSongs.map((item, index) => {
26 | const { resource } = detailResults[index]
27 | const { rateFormats = [], newRateFormats = [] } = resource[0] || {}
28 | const {
29 | androidSize = 0,
30 | size = 0,
31 | androidFileType = '',
32 | fileType = '',
33 | androidUrl = '',
34 | url = '',
35 | } = newRateFormats.length
36 | ? newRateFormats[newRateFormats.length - 1]
37 | : newRateFormats.length
38 | ? rateFormats[rateFormats.length - 1]
39 | : {}
40 | const { pathname } = new URL(url || androidUrl || 'https://music.migu.cn/')
41 | Object.assign(item, {
42 | disabled: !androidSize && !size,
43 | size: size || androidSize,
44 | url: `https://freetyst.nf.migu.cn${pathname}`,
45 | songName: `${joinSingersName(item.singers || item.artists)} - ${removePunctuation(
46 | item.name || item.songName
47 | )}.${fileType || androidFileType}`,
48 | })
49 | })
50 | return {
51 | searchSongs,
52 | totalSongCount,
53 | }
54 | }
55 |
--------------------------------------------------------------------------------
/api/search/utils/index.js:
--------------------------------------------------------------------------------
1 | import https from 'https'
2 |
3 | export const removePunctuation = (str) => {
4 | return str.replace(/[.?\/#|$%\^&\*;:{}+=_`'"~<>]/g, '').replace(/\s{2,}/g, ' ')
5 | }
6 |
7 | export const joinSingersName = (singers) => {
8 | const singersNames = singers.map((singer) => singer.name)
9 | return singersNames.join(',')
10 | }
11 |
12 | export const getSongSizeByUrl = (url) => {
13 | if (!url) return Promise.resolve(0)
14 | return new Promise(async (resolve) => {
15 | https
16 | .get(
17 | url,
18 | {
19 | rejectUnauthorized: false,
20 | },
21 | (res) => {
22 | const length = parseInt(res.headers['content-length'])
23 | if (!isNaN(length) && res.statusCode === 200) {
24 | resolve(length)
25 | } else {
26 | resolve(0)
27 | }
28 | }
29 | )
30 | .on('error', () => {
31 | resolve(0)
32 | })
33 | })
34 | }
35 |
--------------------------------------------------------------------------------
/bin/cli.js:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env node
2 |
3 | import '../dist/index.js'
4 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "musicn",
3 | "version": "1.5.3-beta.0",
4 | "description": "Download music in your command line",
5 | "bin": {
6 | "musicn": "bin/cli.js",
7 | "msc": "bin/cli.js"
8 | },
9 | "scripts": {
10 | "dev": "cross-env IS_DEV=true esno ./src/index.ts",
11 | "start": "vercel dev",
12 | "deploy": "vercel --prod",
13 | "build": "tsup",
14 | "lint": "eslint --ext .ts src/",
15 | "prepare": "husky install",
16 | "release": "bumpp --commit --push --tag",
17 | "test": "vitest"
18 | },
19 | "type": "module",
20 | "repository": {
21 | "type": "git",
22 | "url": "git+https://github.com/zonemeen/musicn.git"
23 | },
24 | "keywords": [
25 | "music",
26 | "musicn",
27 | "SQ",
28 | "ZQ",
29 | "free",
30 | "download",
31 | "music download",
32 | "migu",
33 | "netease-cloud-music",
34 | "terminal",
35 | "command line",
36 | "command-line-tool",
37 | "cli",
38 | "cli-tool"
39 | ],
40 | "engines": {
41 | "node": ">=16.0.0"
42 | },
43 | "files": [
44 | "dist",
45 | "bin",
46 | "public",
47 | "template",
48 | "README.md"
49 | ],
50 | "author": "zonemeen",
51 | "license": "MIT",
52 | "bugs": {
53 | "url": "https://github.com/zonemeen/musicn/issues"
54 | },
55 | "homepage": "https://github.com/zonemeen/musicn#readme",
56 | "dependencies": {
57 | "cac": "^6.7.14",
58 | "cli-progress": "3.11.2",
59 | "colorette": "2.0.19",
60 | "express": "^4.18.2",
61 | "got": "12.3.1",
62 | "inquirer": "^9.2.9",
63 | "open": "^9.1.0",
64 | "ora": "6.1.2",
65 | "portfinder": "^1.0.32",
66 | "pretty-bytes": "6.0.0",
67 | "qrcode-terminal": "^0.12.0",
68 | "update-notifier": "6.0.2"
69 | },
70 | "devDependencies": {
71 | "@actions/exec": "^1.1.1",
72 | "@types/cli-progress": "3.11.0",
73 | "@types/ejs": "^3.1.2",
74 | "@types/express": "^4.17.17",
75 | "@types/inquirer": "9.0.1",
76 | "@types/node": "18.7.6",
77 | "@types/qrcode-terminal": "^0.12.0",
78 | "@types/update-notifier": "6.0.1",
79 | "@typescript-eslint/eslint-plugin": "5.33.1",
80 | "@typescript-eslint/parser": "5.33.1",
81 | "bumpp": "8.2.1",
82 | "cross-env": "^7.0.3",
83 | "eslint": "8.22.0",
84 | "eslint-config-prettier": "8.5.0",
85 | "eslint-plugin-import": "2.26.0",
86 | "eslint-plugin-prettier": "4.2.1",
87 | "esno": "^0.16.3",
88 | "globby": "^13.2.2",
89 | "husky": "8.0.1",
90 | "lint-staged": "13.0.3",
91 | "path-exists": "^5.0.0",
92 | "prettier": "2.7.1",
93 | "sinon": "^14.0.2",
94 | "tsup": "6.2.2",
95 | "typescript": "4.7.4",
96 | "vercel": "^28.20.0",
97 | "vitest": "^0.24.5"
98 | },
99 | "lint-staged": {
100 | "*.ts": [
101 | "prettier --write",
102 | "eslint --fix"
103 | ]
104 | }
105 | }
106 |
--------------------------------------------------------------------------------
/public/index.css:
--------------------------------------------------------------------------------
1 | #app {
2 | padding: 20px 20px 0px 20px;
3 | }
4 | #aplayer {
5 | position: absolute;
6 | width: 99.5%;
7 | bottom: 0;
8 | }
9 | .aplayer.aplayer-withlist
10 | .aplayer-info
11 | .aplayer-controller
12 | .aplayer-time
13 | .aplayer-icon.aplayer-icon-menu {
14 | display: none;
15 | }
16 | .top-wrapper {
17 | display: flex;
18 | align-items: center;
19 | margin-bottom: 12px;
20 | }
21 | .anticon-left,
22 | .anticon-right,
23 | .anticon-double-right {
24 | margin-top: 30%;
25 | }
26 | .anticon-caret-right {
27 | font-size: 18px;
28 | padding-left: 8%;
29 | }
30 | .ant-list-item-meta-title {
31 | margin-top: 6px;
32 | }
33 | .ant-list-items {
34 | padding-right: 24px;
35 | overflow: auto;
36 | height: calc(100vh - 236px);
37 | }
38 | .ant-input-search {
39 | width: 240px;
40 | }
41 | .list-item-click {
42 | cursor: pointer;
43 | }
44 | .list-item-disabled {
45 | cursor: not-allowed;
46 | }
47 | .list-item-disabled:active {
48 | pointer-events: none;
49 | }
50 | ::-webkit-scrollbar {
51 | width: 6px;
52 | }
53 | ::-webkit-scrollbar-thumb {
54 | border-radius: 3px;
55 | background-color: #eeeeee;
56 | }
57 | ::-webkit-scrollbar-thumb:hover {
58 | background-color: #cccccc;
59 | }
60 |
--------------------------------------------------------------------------------
/public/music.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/zonemeen/musicn/14b3faa00aebfdb235022eb255bc4a346ed66c69/public/music.png
--------------------------------------------------------------------------------
/public/music.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/choose.ts:
--------------------------------------------------------------------------------
1 | import inquirer from 'inquirer'
2 | import names from './names'
3 | import type { SongInfo } from './types'
4 |
5 | const choose = ({ searchSongs, options }: SongInfo) =>
6 | inquirer.prompt([
7 | {
8 | type: 'checkbox',
9 | name: 'songs',
10 | message: '选择歌曲',
11 | pageSize: 20,
12 | choices: searchSongs.map((song, index) => names({ song, index, options })),
13 | },
14 | ])
15 |
16 | export default choose
17 |
--------------------------------------------------------------------------------
/src/command.ts:
--------------------------------------------------------------------------------
1 | import { cac } from 'cac'
2 | import { red } from 'colorette'
3 | import updateNotifier from 'update-notifier'
4 | import pkg from '../package.json'
5 |
6 | export default (() => {
7 | const cli = cac('musicn')
8 | updateNotifier({ pkg }).notify()
9 |
10 | cli
11 | .option('-n, --number ', '搜索时的页码数', { default: 1 })
12 | .option('-s, --size ', '搜索时的歌曲数量', { default: 20 })
13 | .option('-b, --base ', 'base URL')
14 | .option('-i, --songListId ', '歌单ID')
15 | .option('-l, --lyric', '是否下载歌词')
16 | .option('-p, --path ', '音乐批量下载的目标目录路径')
17 | .option('-m, --migu', '默认是咪咕的服务')
18 | .option('-g, --kugou', '酷狗的服务')
19 | .option('-w, --wangyi', '网易云的服务')
20 | .option('-q, --qrcode', '是否开启本地播放链接(手机可用二维码访问)', { default: false })
21 | .option('-P, --port ', '更改本地生成链接默认端口')
22 | .option('-o, --open', '开启本地播放链接时是否自动打开浏览器')
23 |
24 | cli.help()
25 |
26 | cli.version(pkg.version)
27 |
28 | const { args, options } = cli.parse()
29 | const { wangyi, migu, kugou, help, version } = options
30 | const serviceNum = [wangyi, migu, kugou].filter(Boolean).length
31 | if (serviceNum > 1) {
32 | console.error(red('同时只允许输入一种服务'))
33 | process.exit(1)
34 | }
35 | if (help || version) process.exit()
36 | options.migu = !(wangyi || kugou)
37 | options.service = wangyi ? 'wangyi' : kugou ? 'kugou' : 'migu'
38 | const content = { text: args.join(' '), options }
39 | process.stdout.write(JSON.stringify(content))
40 | return content
41 | })()
42 |
--------------------------------------------------------------------------------
/src/download.ts:
--------------------------------------------------------------------------------
1 | import got from 'got'
2 | import cliProgress from 'cli-progress'
3 | import prettyBytes from 'pretty-bytes'
4 | import { red, green } from 'colorette'
5 | import { pipeline } from 'node:stream/promises'
6 | import { join, basename } from 'node:path'
7 | import { unlink } from 'node:fs/promises'
8 | import { existsSync, mkdirSync, createWriteStream } from 'node:fs'
9 | import lyricDownload from './services/lyric'
10 | import type { SongInfo } from './types'
11 |
12 | const barList: cliProgress.SingleBar[] = []
13 | const songNameMap = new Map()
14 | const unfinishedPathMap = new Map()
15 |
16 | const multiBar = new cliProgress.MultiBar({
17 | format: '[\u001b[32m{bar}\u001b[0m] | {file} | {value}/{total}',
18 | hideCursor: true,
19 | barCompleteChar: '#',
20 | barIncompleteChar: '#',
21 | barGlue: '\u001b[33m',
22 | barsize: 30,
23 | stopOnComplete: true,
24 | noTTYOutput: true,
25 | forceRedraw: true,
26 | formatValue(value, _, type) {
27 | if (type === 'total' || type === 'value') {
28 | return prettyBytes(Number(value))
29 | }
30 | return String(value)
31 | },
32 | })
33 |
34 | const downloadSong = (song: SongInfo, index: number) => {
35 | let { songName, songDownloadUrl, lyricDownloadUrl, songSize, options } = song
36 | const { lyric: withLyric = false, path: targetDir = process.cwd(), service } = options
37 | return new Promise(async (resolve) => {
38 | if (songNameMap.has(songName)) {
39 | songNameMap.set(songName, Number(songNameMap.get(songName)) + 1)
40 | const [name, extension] = songName.split('.')
41 | const newName = `${name}(${songNameMap.get(songName)})`
42 | songName = `${newName}.${extension}`
43 | } else {
44 | songNameMap.set(songName, 0)
45 | }
46 | const songPath = join(targetDir, songName)
47 | const lrcPath = join(targetDir, `${songName.split('.')[0]}.lrc`)
48 |
49 | barList.push(multiBar.create(songSize, 0, { file: songName }))
50 |
51 | unfinishedPathMap.set(songPath, '')
52 |
53 | if (!existsSync(targetDir)) mkdirSync(targetDir)
54 |
55 | if (withLyric) {
56 | await lyricDownload[service](lrcPath, lyricDownloadUrl).catch(() => {
57 | createWriteStream(lrcPath).write('[00:00.00]无歌词')
58 | })
59 | }
60 |
61 | const onError = (err: any, songPath: string) => {
62 | let timer = setInterval(() => {
63 | const bar: any = barList[index]
64 | const STEP_COUNT = 49999
65 | bar.options.format = '[\u001b[31m{bar}\u001b[0m] | {file} | {value}/{total}'
66 | if (songSize - bar.value >= STEP_COUNT) {
67 | return bar.increment(STEP_COUNT)
68 | }
69 | bar.increment(songSize - bar.value)
70 | clearInterval(timer)
71 | }, 3)
72 | if (unfinishedPathMap.has(songPath)) unfinishedPathMap.set(songPath, err)
73 | }
74 |
75 | try {
76 | const fileReadStream = got.stream(songDownloadUrl)
77 | fileReadStream.on('response', async () => {
78 | fileReadStream.off('error', (err) => {
79 | onError(err, songPath)
80 | })
81 | await pipeline(fileReadStream, createWriteStream(songPath))
82 | unfinishedPathMap.delete(songPath)
83 | resolve(true)
84 | })
85 |
86 | fileReadStream.on('downloadProgress', ({ transferred }) => {
87 | barList[index].update(transferred)
88 | })
89 |
90 | fileReadStream.once('error', (err) => {
91 | onError(err, songPath)
92 | })
93 | } catch (err) {
94 | onError(err, songPath)
95 | }
96 | })
97 | }
98 |
99 | const download = (songs: SongInfo[]) => {
100 | if (!songs.length) {
101 | console.error(red('请选择歌曲'))
102 | process.exit(1)
103 | }
104 | console.log(green('下载开始...'))
105 | multiBar.on('stop', () => {
106 | let errorMessage = ''
107 | const { size } = unfinishedPathMap
108 | if (size) {
109 | errorMessage = Array.from(unfinishedPathMap.entries()).reduce((pre, cur, index) => {
110 | pre += `\n${index + 1}.${basename(cur[0])}下载失败,报错信息:${cur[1]}`
111 | return pre
112 | }, '失败信息:')
113 | }
114 | console.log(
115 | green(
116 | `下载完成,成功 ${songs.length - size} 首,失败 ${size} 首${size ? '\n' : ''}${red(
117 | errorMessage
118 | )}`
119 | )
120 | )
121 | })
122 |
123 | const exitEventTypes = ['exit', 'SIGINT', 'SIGHUP', 'SIGBREAK', 'SIGTERM']
124 | for (let i = 0; i < exitEventTypes.length; i++) {
125 | process.on(exitEventTypes[i], () => {
126 | Promise.all([...unfinishedPathMap.keys()].map((path) => unlink(path)))
127 | process.exit()
128 | })
129 | }
130 | return Promise.all(songs.map((song, index) => downloadSong(song, index)))
131 | }
132 | export default download
133 |
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
1 | import command from './command'
2 | import choose from './choose'
3 | import searchMusic from './searchMusic'
4 | import download from './download'
5 | import qrcodeGenerator from './qrcode'
6 | import type { SongInfo, CommandOptions } from './types'
7 |
8 | !(async () => {
9 | const { options } = command
10 | if (options.qrcode) {
11 | return await qrcodeGenerator(options as CommandOptions)
12 | }
13 | const result = await searchMusic(command)
14 | const { songs = [] } = await choose(result)
15 | await download(songs)
16 | })()
17 |
--------------------------------------------------------------------------------
/src/names.ts:
--------------------------------------------------------------------------------
1 | import prettyBytes from 'pretty-bytes'
2 | import type { NamesProps } from './types'
3 |
4 | const names = ({ song, index, options }: NamesProps) => {
5 | const { songName, url, size, disabled, lyricUrl } = song
6 |
7 | return {
8 | name: `${index + 1}. ${songName} - ${prettyBytes(Number(size))}`,
9 | disabled,
10 | value: {
11 | songName,
12 | songDownloadUrl: url,
13 | lyricDownloadUrl: lyricUrl,
14 | songSize: size,
15 | options,
16 | },
17 | }
18 | }
19 |
20 | export default names
21 |
--------------------------------------------------------------------------------
/src/qrcode/index.ts:
--------------------------------------------------------------------------------
1 | import { fileURLToPath } from 'node:url'
2 | import { resolve, dirname, join } from 'node:path'
3 | import { pipeline } from 'node:stream/promises'
4 | import { existsSync, mkdirSync, createWriteStream, readFileSync } from 'node:fs'
5 | import got from 'got'
6 | import portfinder from 'portfinder'
7 | import qrcode from 'qrcode-terminal'
8 | import open from 'open'
9 | import express, { NextFunction, Request, Response } from 'express'
10 | import search from '../services/search'
11 | import lyricDownload from '../services/lyric'
12 | import { getNetworkAddress } from '../utils'
13 | import { version } from '../../package.json'
14 | import type { ServiceType, CommandOptions } from '../types'
15 |
16 | interface DownloadRequestType {
17 | service: ServiceType
18 | url: string
19 | songName: string
20 | lyricUrl: string
21 | }
22 |
23 | interface SearchRequestType {
24 | service: ServiceType
25 | text: string
26 | pageNum: string
27 | pageSize: string
28 | }
29 |
30 | const __dirname = dirname(fileURLToPath(import.meta.url))
31 |
32 | const config = {
33 | qrcode: {
34 | small: true,
35 | },
36 | portfinder: {
37 | port: 7478,
38 | stopPort: 8000,
39 | },
40 | }
41 |
42 | export default async (options: CommandOptions) => {
43 | const app = express()
44 | let { port, open: isOpen, path, lyric: withLyric, base = '' } = options
45 |
46 | const indexPath = resolve(
47 | __dirname,
48 | process.env.IS_DEV === 'true' ? '../../template/index.html' : '../template/index.html'
49 | )
50 |
51 | let htmlContent = readFileSync(indexPath, 'utf8')
52 |
53 | htmlContent = htmlContent
54 | .replace(/{{base}}/g, base?.length ? `/${base}` : '')
55 | .replace(/{{version}}/g, version)
56 |
57 | app.use(
58 | `${base?.length ? `/${base}` : ''}/${version}`,
59 | express.static(
60 | resolve(__dirname, process.env.IS_DEV === 'true' ? '../../public' : '../public'),
61 | {
62 | maxAge: 31536000,
63 | }
64 | )
65 | )
66 |
67 | app.get(`/${base}`, (_, res) => {
68 | res.send(htmlContent)
69 | })
70 |
71 | app.get(
72 | '/search',
73 | async (
74 | req: Request<
75 | Record,
76 | Record,
77 | Record,
78 | SearchRequestType
79 | >,
80 | res: Response
81 | ) => {
82 | const { service, text, pageNum, pageSize = '20' } = req.query
83 | const { searchSongs, totalSongCount } = await search[service]({
84 | text,
85 | pageNum,
86 | pageSize,
87 | })
88 | const lyricList = (await Promise.allSettled(
89 | searchSongs.map(({ lyricUrl }) => lyricDownload[service](null, lyricUrl!))
90 | )) as { value: string | undefined }[]
91 | searchSongs.forEach((song, index) => {
92 | song.lrc = lyricList[index].value ?? '[00:00.00]无歌词'
93 | })
94 | res.send({ searchSongs, totalSongCount })
95 | }
96 | )
97 |
98 | app.get(
99 | '/download',
100 | async (
101 | req: Request<
102 | Record,
103 | Record,
104 | Record,
105 | DownloadRequestType
106 | >,
107 | res: Response
108 | ) => {
109 | const { service, url, songName, lyricUrl } = req.query
110 | if (path) {
111 | if (!existsSync(path)) mkdirSync(path)
112 | const songPath = join(path, songName)
113 | await pipeline(got.stream(url), createWriteStream(songPath))
114 | if (withLyric) {
115 | const lrcPath = join(path, `${songName.split('.')[0]}.lrc`)
116 | await lyricDownload[service](lrcPath, lyricUrl).catch(() => {
117 | createWriteStream(lrcPath).write('[00:00.00]无歌词')
118 | })
119 | }
120 | res.send({ download: 'success' })
121 | } else {
122 | got.stream(url).pipe(res)
123 | }
124 | }
125 | )
126 |
127 | app.use((err: Error, req: Request, res: Response, next: NextFunction) => {
128 | res.status(res.statusCode || 500)
129 | res.render('error', {
130 | message: err.message,
131 | error: err,
132 | })
133 | })
134 |
135 | const realPort = port ?? (await portfinder.getPortPromise(config.portfinder))
136 |
137 | const onStart = () => {
138 | const address = `http://${getNetworkAddress()}:${realPort}/${base}`
139 |
140 | console.log('\n扫描二维码,播放及下载音乐')
141 | qrcode.generate(address, config.qrcode)
142 | console.log(`访问链接: ${address}\n`)
143 | isOpen && open(address)
144 | }
145 |
146 | app.listen(realPort, onStart)
147 | }
148 |
--------------------------------------------------------------------------------
/src/searchMusic.ts:
--------------------------------------------------------------------------------
1 | import ora from 'ora'
2 | import { cyan, red } from 'colorette'
3 | import search from './services/search'
4 | import type { SongInfo } from './types'
5 |
6 | const searchMusic = async ({ text, options }: SongInfo) => {
7 | try {
8 | const { number: pageNum, size: pageSize, service, songListId, kugou } = options
9 | const intRegex = /^[1-9]\d*$/
10 | if (text === '' && !songListId) {
11 | console.error(red('请输入歌曲名称或歌手名字'))
12 | process.exit(1)
13 | }
14 |
15 | if (text && songListId) {
16 | console.error(red('不能同时输入搜索词和歌单id'))
17 | process.exit(1)
18 | }
19 |
20 | if (!intRegex.test(pageNum)) {
21 | console.error(red('页码数应是大于0的整数,请重新输入'))
22 | process.exit(1)
23 | }
24 |
25 | if (!intRegex.test(pageSize)) {
26 | console.error(red('歌曲数量应是大于0的整数,请重新输入'))
27 | process.exit(1)
28 | }
29 |
30 | if (kugou && songListId) {
31 | console.error(red('kugou服务不支持歌单下载'))
32 | process.exit(1)
33 | }
34 |
35 | const spinner = ora(cyan('搜索ing')).start()
36 | const { searchSongs, totalSongCount } = await search[service]({
37 | text,
38 | pageNum,
39 | pageSize,
40 | songListId,
41 | })
42 |
43 | if (!searchSongs.length) {
44 | if (text && totalSongCount === undefined) {
45 | spinner.fail(red(`没搜索到 ${text} 的相关结果`))
46 | process.exit(1)
47 | }
48 | if (songListId && totalSongCount === undefined) {
49 | spinner.fail(red(`没搜索到歌单ID: ${songListId} 的相关结果`))
50 | process.exit(1)
51 | }
52 | spinner.fail(red('搜索页码超出范围,请重新输入'))
53 | process.exit(1)
54 | }
55 | spinner.stop()
56 | return { searchSongs, options }
57 | } catch (err: any) {
58 | console.error(red(err))
59 | process.exit(1)
60 | }
61 | }
62 |
63 | export default searchMusic
64 |
--------------------------------------------------------------------------------
/src/services/lyric/index.ts:
--------------------------------------------------------------------------------
1 | import migu from './migu'
2 | import wangyi from './wangyi'
3 | import kuwo from './kuwo'
4 | import kugou from './kugou'
5 |
6 | export default {
7 | migu,
8 | wangyi,
9 | kuwo,
10 | kugou,
11 | }
12 |
--------------------------------------------------------------------------------
/src/services/lyric/kugou.ts:
--------------------------------------------------------------------------------
1 | import got from 'got'
2 | import { createWriteStream } from 'node:fs'
3 | import { decodeLyric, parseLyric } from '../../utils'
4 |
5 | export default async (lrcPath: string | null, lyricDownloadUrl: string) => {
6 | let lrc, lrcFileWriteStream
7 | const { candidates }: { candidates: { id: string; accesskey: string }[] } = await got(
8 | lyricDownloadUrl
9 | ).json()
10 | if (lrcPath) {
11 | lrcFileWriteStream = createWriteStream(lrcPath)
12 | }
13 | if (candidates.length) {
14 | const { id, accesskey } = candidates[0]
15 | const lyricDetailUrl = `http://lyrics.kugou.com/download?ver=1&client=pc&id=${id}&accesskey=${accesskey}&fmt=krc&charset=utf8`
16 | const { content = '' }: { content: string } = await got(lyricDetailUrl).json()
17 | const decode = await decodeLyric(content)
18 | const { lyric = '' } = parseLyric(decode as string)
19 | lrcFileWriteStream?.write(lyric)
20 | lrc = lyric
21 | } else {
22 | lrc = `[00:00.00]${lrcPath?.split('.')[0] ?? '无歌词'}`
23 | lrcFileWriteStream?.write(lrc)
24 | }
25 | if (!lrcPath) return lrc
26 | }
27 |
--------------------------------------------------------------------------------
/src/services/lyric/kuwo.ts:
--------------------------------------------------------------------------------
1 | import got from 'got'
2 | import { createWriteStream } from 'node:fs'
3 | import { convertToStandardTime } from '../../utils'
4 |
5 | export default async (lrcPath: string | null, lyricDownloadUrl: string) => {
6 | const {
7 | data: { lrclist },
8 | }: { data: { lrclist: { time: string; lineLyric: string }[] } } = await got(
9 | lyricDownloadUrl
10 | ).json()
11 | let lyric = ''
12 | if (lrclist && lrclist.length) {
13 | for (const lrc of lrclist) {
14 | lyric += `[${convertToStandardTime(lrc.time)}]${lrc.lineLyric}\n`
15 | }
16 | } else {
17 | lyric = '[00:00.00]无歌词'
18 | }
19 | if (lrcPath) {
20 | const lrcFileWriteStream = createWriteStream(lrcPath)
21 | lrcFileWriteStream.write(lyric)
22 | }
23 | if (!lrcPath) return lyric
24 | }
25 |
--------------------------------------------------------------------------------
/src/services/lyric/migu.ts:
--------------------------------------------------------------------------------
1 | import got from 'got'
2 | import { pipeline } from 'node:stream/promises'
3 | import { createWriteStream } from 'node:fs'
4 | import { streamToString } from '../../utils'
5 |
6 | export default async (lrcPath: string | null, lyricDownloadUrl: string) => {
7 | const stream = got.stream(lyricDownloadUrl)
8 | if (lrcPath) {
9 | await pipeline(stream, createWriteStream(lrcPath))
10 | } else {
11 | return await streamToString(stream)
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/src/services/lyric/wangyi.ts:
--------------------------------------------------------------------------------
1 | import got from 'got'
2 | import { createWriteStream } from 'node:fs'
3 |
4 | export default async (lrcPath: string | null, lyricDownloadUrl: string) => {
5 | let lrc, lrcFileWriteStream
6 | const {
7 | lrc: { lyric },
8 | }: { lrc: { lyric: string } } = await got(lyricDownloadUrl).json()
9 | if (lrcPath) {
10 | lrcFileWriteStream = createWriteStream(lrcPath)
11 | }
12 | if (lyric) {
13 | lrcFileWriteStream?.write(lyric)
14 | lrc = lyric
15 | } else {
16 | lrc = `[00:00.00]${lrcPath?.split('.')[0] ?? '无歌词'}`
17 | lrcFileWriteStream?.write(lrc)
18 | }
19 | if (!lrcPath) return lrc
20 | }
21 |
--------------------------------------------------------------------------------
/src/services/search/index.ts:
--------------------------------------------------------------------------------
1 | import migu from './migu'
2 | import wangyi from './wangyi'
3 | import kuwo from './kuwo'
4 | import kugou from './kugou'
5 |
6 | export default {
7 | migu,
8 | wangyi,
9 | kuwo,
10 | kugou,
11 | }
12 |
--------------------------------------------------------------------------------
/src/services/search/kugou.ts:
--------------------------------------------------------------------------------
1 | import got from 'got'
2 | import { createHash } from 'node:crypto'
3 | import { removePunctuation, kgCookie } from '../../utils'
4 | import type { SearchSongInfo, SearchProps } from '../../types'
5 |
6 | export default async ({ text, pageNum, pageSize }: SearchProps) => {
7 | if (!text) return { searchSongs: [] }
8 | const searchUrl = `http://msearchcdn.kugou.com/api/v3/search/song?pagesize=${pageSize}&keyword=${encodeURIComponent(
9 | text
10 | )}&page=${pageNum}`
11 | const {
12 | data: { info: searchSongs, total },
13 | }: { data: { info: SearchSongInfo[]; total: number } } = await got(searchUrl).json()
14 | const totalSongCount = total || undefined
15 | await Promise.all(
16 | searchSongs.map(async (song) => {
17 | const detailUrl = `http://trackercdn.kugou.com/i/v2/?key=${createHash('md5')
18 | .update(`${song.hash}kgcloudv2`)
19 | .digest('hex')}&hash=${song.hash}&br=hq&appid=1005&pid=2&cmd=25&behavior=play`
20 | const coverUrl = `https://wwwapi.kugou.com/yy/index.php?r=play/getdata&hash=${song.hash}`
21 | const { url = [], fileSize = 0 }: { url: string[]; fileSize: number } = await got(
22 | detailUrl
23 | ).json()
24 | const {
25 | data: { img },
26 | }: { data: { img: string } } = await got(coverUrl, {
27 | method: 'get',
28 | headers: {
29 | Cookie: kgCookie,
30 | },
31 | }).json()
32 | const [artists, name] = removePunctuation(song.filename.replaceAll('、', ',')).split(' - ')
33 | Object.assign(song, {
34 | id: song.hash,
35 | url: url[0],
36 | cover: img,
37 | size: fileSize,
38 | disabled: !fileSize,
39 | songName: `${name} - ${artists}.mp3`,
40 | lyricUrl: `http://lyrics.kugou.com/search?ver=1&man=yes&client=pc&hash=${song.hash}`,
41 | })
42 | })
43 | )
44 | return {
45 | searchSongs,
46 | totalSongCount,
47 | }
48 | }
49 |
--------------------------------------------------------------------------------
/src/services/search/kuwo.ts:
--------------------------------------------------------------------------------
1 | import got from 'got'
2 | import { removePunctuation, getSongSizeByUrl } from '../../utils'
3 | import type { SearchSongInfo, SearchProps } from '../../types'
4 |
5 | export default async ({ text, pageNum, pageSize, songListId }: SearchProps) => {
6 | let searchSongs: SearchSongInfo[], totalSongCount
7 | if (songListId) {
8 | const songListSearchUrl = `https://nplserver.kuwo.cn/pl.svc?op=getlistinfo&pid=${songListId}&pn=${
9 | Number(pageNum) - 1
10 | }&rn=${pageSize}&encode=utf8&keyset=pl2012&vipver=MUSIC_9.0.5.0_W1`
11 | const { musiclist, total }: { musiclist: SearchSongInfo[]; total: number } = await got(
12 | songListSearchUrl
13 | ).json()
14 | totalSongCount = Number(total) || undefined
15 | searchSongs = musiclist
16 | } else {
17 | const normalSearchUrl = `https://search.kuwo.cn/r.s?client=kt&all=${encodeURIComponent(
18 | text
19 | )}&pn=${Number(pageNum) - 1}&rn=${pageSize}&vipver=1&ft=music&encoding=utf8&rformat=json&mobi=1`
20 | const { abslist, TOTAL }: { abslist: SearchSongInfo[]; TOTAL: number } = await got(
21 | normalSearchUrl
22 | ).json()
23 | totalSongCount = Number(TOTAL) || undefined
24 | searchSongs = abslist
25 | }
26 | await Promise.all(
27 | searchSongs.map(async (item) => {
28 | const detailUrl = `https://www.kuwo.cn/api/v1/www/music/playUrl?mid=${
29 | item.DC_TARGETID || item.id
30 | }&type=1`
31 | const {
32 | data: { url },
33 | }: { data: { url: string } } = await got(detailUrl).json()
34 | const size = await getSongSizeByUrl(url)
35 | const artist = item.ARTIST || item.artist
36 | Object.assign(item, {
37 | id: item.DC_TARGETID || item.id,
38 | url,
39 | size,
40 | disabled: !size,
41 | songName: `${removePunctuation(item.NAME || item.name)} - ${artist.replaceAll(
42 | '&',
43 | ','
44 | )}.mp3`,
45 | lyricUrl: `https://m.kuwo.cn/newh5/singles/songinfoandlrc?musicId=${item.DC_TARGETID}`,
46 | })
47 | })
48 | )
49 | return {
50 | searchSongs,
51 | totalSongCount,
52 | }
53 | }
54 |
--------------------------------------------------------------------------------
/src/services/search/migu.ts:
--------------------------------------------------------------------------------
1 | import got from 'got'
2 | import { removePunctuation, joinSingersName, getSongSizeByUrl } from '../../utils'
3 | import type { SearchSongInfo, SearchProps } from '../../types'
4 |
5 | export default async ({ text, pageNum, pageSize, songListId }: SearchProps) => {
6 | let searchSongs, totalSongCount
7 | if (songListId) {
8 | const songListSearchUrl = `https://app.c.nf.migu.cn/MIGUM3.0/v1.0/user/queryMusicListSongs.do?musicListId=${songListId}&pageNo=${pageNum}&pageSize=${pageSize}`
9 | const { list, totalCount }: { list: SearchSongInfo[]; totalCount: number } = await got(
10 | songListSearchUrl
11 | ).json()
12 | searchSongs = list
13 | totalSongCount = totalCount || undefined
14 | } else {
15 | const normalSearchUrl = `https://pd.musicapp.migu.cn/MIGUM3.0/v1.0/content/search_all.do?text=${encodeURIComponent(
16 | text
17 | )}&pageNo=${pageNum}&pageSize=${pageSize}&searchSwitch={song:1}`
18 | const {
19 | songResultData,
20 | }: { songResultData: { result?: SearchSongInfo[]; totalCount?: number } } = await got(
21 | normalSearchUrl
22 | ).json()
23 | searchSongs = songResultData?.result || []
24 | totalSongCount = songResultData?.totalCount
25 | }
26 | await Promise.all(
27 | searchSongs.map(async (song) => {
28 | const detailUrl = `https://c.musicapp.migu.cn/MIGUM2.0/v1.0/content/resourceinfo.do?copyrightId=${song.copyrightId}&resourceType=0`
29 | const {
30 | resource,
31 | }: {
32 | resource: { audioUrl: string }[]
33 | } = await got(detailUrl).json()
34 | const { audioUrl } = resource[0] || {}
35 | const { pathname } = new URL(audioUrl || 'https://music.migu.cn/')
36 | const url = decodeURIComponent(`https://freetyst.nf.migu.cn${pathname}`).replace(
37 | '彩铃/6_mp3-128kbps',
38 | '标清高清/MP3_320_16_Stero'
39 | )
40 | const size = audioUrl ? await getSongSizeByUrl(url) : 0
41 | const fileType = audioUrl?.replace(/.+\.(mp3|flac)/, '$1') ?? 'mp3'
42 | Object.assign(song, {
43 | disabled: !size,
44 | cover: song.imgItems[0]?.img,
45 | size: size,
46 | url,
47 | songName: `${removePunctuation(song.name || song.songName)} - ${joinSingersName(
48 | song.singers || song.artists
49 | )}.${fileType}`,
50 | })
51 | })
52 | )
53 | return {
54 | searchSongs,
55 | totalSongCount,
56 | }
57 | }
58 |
--------------------------------------------------------------------------------
/src/services/search/wangyi.ts:
--------------------------------------------------------------------------------
1 | import got from 'got'
2 | import { removePunctuation, joinSingersName, encryptParams } from '../../utils'
3 | import type { SearchSongInfo, SearchProps } from '../../types'
4 |
5 | export default async ({ text, pageNum, pageSize, songListId }: SearchProps) => {
6 | let searchSongs, totalSongCount
7 | if (songListId) {
8 | const songListSearchUrl = `https://music.163.com/api/v3/playlist/detail?id=${songListId}`
9 | const { playlist }: { playlist: { trackIds: { id: string }[] } } = await got(
10 | songListSearchUrl
11 | ).json()
12 | const searchSongsIds =
13 | playlist?.trackIds.slice(
14 | (Number(pageNum) - 1) * Number(pageSize),
15 | Number(pageNum) * Number(pageSize)
16 | ) || []
17 | const ids = searchSongsIds.map(({ id }) => id)
18 | const { songs }: { songs: SearchSongInfo[] } = await got(
19 | 'https://music.163.com/weapi/v3/song/detail',
20 | {
21 | method: 'post',
22 | headers: {
23 | 'User-Agent':
24 | 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/60.0.3112.90 Safari/537.36',
25 | origin: 'https://music.163.com',
26 | },
27 | form: encryptParams({
28 | c: '[' + ids.map((id: string) => '{"id":' + id + '}').join(',') + ']',
29 | ids: '[' + ids.join(',') + ']',
30 | }),
31 | }
32 | ).json()
33 | searchSongs = songs
34 | totalSongCount = playlist?.trackIds?.length || undefined
35 | } else {
36 | const normalSearchUrl = `https://music.163.com/api/search/get/web?s=${encodeURIComponent(
37 | text
38 | )}&type=1&limit=${pageSize}&offset=${(Number(pageNum) - 1) * 20}`
39 | const {
40 | result: { songs = [], songCount },
41 | }: { result: { songs: SearchSongInfo[]; songCount: number } } = await got(
42 | normalSearchUrl
43 | ).json()
44 | searchSongs = songs
45 | totalSongCount = songCount
46 | }
47 | await Promise.all(
48 | searchSongs.map(async (song) => {
49 | const { songs }: any = await got('https://music.163.com/weapi/v3/song/detail', {
50 | method: 'post',
51 | headers: {
52 | 'User-Agent':
53 | 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/60.0.3112.90 Safari/537.36',
54 | Referer: 'https://music.163.com/song?id=' + song.id,
55 | origin: 'https://music.163.com',
56 | },
57 | form: encryptParams({
58 | c: `[{"id":${song.id}}]`,
59 | ids: `[${song.id}]`,
60 | }),
61 | }).json()
62 | song.cover = songs[0].al.picUrl
63 | const detailUrl = `https://music.163.com/api/song/enhance/player/url/v1?id=${song.id}&ids=[${song.id}]&level=standard&encodeType=mp3`
64 | const { data }: { data: { id: string; url: string; size: number }[] } = await got(
65 | detailUrl
66 | ).json()
67 | const { id, url, size } = data[0]
68 | Object.assign(song, {
69 | url,
70 | size,
71 | disabled: !size,
72 | songName: `${removePunctuation(song.name)} - ${removePunctuation(
73 | joinSingersName(songListId ? song.ar : song.artists)
74 | )}.mp3`,
75 | lyricUrl: `https://music.163.com/api/song/lyric?id=${id}&lv=1`,
76 | })
77 | })
78 | )
79 | return {
80 | searchSongs,
81 | totalSongCount,
82 | }
83 | }
84 |
--------------------------------------------------------------------------------
/src/types.ts:
--------------------------------------------------------------------------------
1 | export interface Artist {
2 | name: string
3 | img1v1Url?: string
4 | }
5 |
6 | export interface RateFormat {
7 | size?: string
8 | androidSize?: string
9 | fileType?: string
10 | androidFileType?: string
11 | url: string
12 | androidUrl: string
13 | }
14 |
15 | interface MiguImgType {
16 | imgSizeType: string
17 | img: string
18 | }
19 |
20 | export type ServiceType = 'migu' | 'kuwo' | 'wangyi' | 'kugou'
21 |
22 | export interface CommandOptions {
23 | lyric?: boolean
24 | path?: string
25 | number: string
26 | size: string
27 | kugou?: boolean
28 | kuwo?: boolean
29 | wangyi?: boolean
30 | migu?: boolean
31 | qrcode: boolean
32 | port?: string
33 | songListId: string
34 | service: ServiceType
35 | open?: boolean
36 | base?: string
37 | }
38 |
39 | export interface SearchSongInfo {
40 | id?: string
41 | contentId?: string
42 | copyrightId?: string
43 | url: string
44 | size: number
45 | name: string
46 | songName: string
47 | lyricUrl?: string
48 | DC_TARGETID?: string
49 | NAME: string
50 | disabled?: boolean
51 | hash?: string
52 | filename: string
53 | fileSize: number
54 | ARTIST: string
55 | artist: string
56 | cover: string
57 | artists: Artist[]
58 | singers: Artist[]
59 | ar: Artist[]
60 | lrc?: string
61 | newRateFormats?: RateFormat[]
62 | rateFormats?: RateFormat[]
63 | imgItems: MiguImgType[]
64 | }
65 |
66 | export interface SearchProps {
67 | text: string
68 | pageNum: string
69 | pageSize: string
70 | songListId?: string
71 | }
72 |
73 | export interface NamesProps {
74 | song: SearchSongInfo
75 | index: number
76 | options: CommandOptions
77 | }
78 |
79 | export interface SongInfo {
80 | songName: string
81 | songDownloadUrl: string
82 | lyricDownloadUrl: string
83 | songSize: number
84 | options: CommandOptions
85 | searchSongs: SearchSongInfo[]
86 | text: string
87 | }
88 |
--------------------------------------------------------------------------------
/src/utils/index.ts:
--------------------------------------------------------------------------------
1 | import { get } from 'node:https'
2 | import { networkInterfaces } from 'node:os'
3 | import { inflate } from 'node:zlib'
4 | import {
5 | createCipheriv,
6 | publicEncrypt,
7 | randomBytes,
8 | constants,
9 | BinaryLike,
10 | CipherKey,
11 | } from 'node:crypto'
12 | import type { Request } from 'got'
13 | import type { Artist } from '../types'
14 |
15 | const iv = Buffer.from('0102030405060708')
16 | const presetKey = Buffer.from('0CoJUm6Qyw8W8jud')
17 | const base62 = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
18 | const publicKey =
19 | '-----BEGIN PUBLIC KEY-----\nMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDgtQn2JZ34ZC28NWYpAUd98iZ37BUrX/aKzmFbt7clFSs6sXqHauqKWqdtLkF2KexO40H1YTX8z2lSgBBOAxLsvaklV8k4cBFK9snQXE9/DDaFt6Rr7iVZMldczhC0JNgTz+SHXT6CBHuX3e9SdB1Ua44oncaTWz7OBGLbCiK45wIDAQAB\n-----END PUBLIC KEY-----'
20 |
21 | export const kgCookie =
22 | 'kg_mid=e7c09bf4e2ff701959e419aa6259f5e1; kg_dfid=2UHiuH0E3doo4ZW8ud01Teb3; kg_dfid_collect=d41d8cd98f00b204e9800998ecf8427e; Hm_lvt_aedee6983d4cfc62f509129360d6bb3d=1690770238; musicwo17=kugou; Hm_lpvt_aedee6983d4cfc62f509129360d6bb3d=1690771797'
23 |
24 | export const getNetworkAddress = () => {
25 | for (const interfaceDetails of Object.values(networkInterfaces())) {
26 | if (!interfaceDetails) continue
27 | for (const details of interfaceDetails) {
28 | const { address, family, internal } = details
29 | if (family === 'IPv4' && !internal) return address
30 | }
31 | }
32 | }
33 |
34 | export const removePunctuation = (str: string) => {
35 | return str.replace(/[.?\/#|$%\^&\*;:{}+=_`'"~<>]/g, '').replace(/\s{2,}/g, ' ')
36 | }
37 |
38 | export const joinSingersName = (singers: Artist[]) => {
39 | const singersNames = singers.map((singer) => singer.name)
40 | return singersNames.join(',')
41 | }
42 |
43 | export const getSongSizeByUrl = (url: string) => {
44 | if (!url) return Promise.resolve(0)
45 | return new Promise((resolve) => {
46 | get(
47 | url,
48 | {
49 | rejectUnauthorized: false,
50 | },
51 | (res) => {
52 | const length = parseInt(res.headers['content-length'])
53 | if (!isNaN(length) && res.statusCode === 200) {
54 | resolve(length)
55 | } else {
56 | resolve(0)
57 | }
58 | }
59 | ).on('error', () => {
60 | resolve(0)
61 | })
62 | })
63 | }
64 |
65 | // https://github.com/lyswhut/lx-music-desktop/issues/296#issuecomment-683285784
66 | const encKey = Buffer.from(
67 | // @ts-ignore
68 | [0x40, 0x47, 0x61, 0x77, 0x5e, 0x32, 0x74, 0x47, 0x51, 0x36, 0x31, 0x2d, 0xce, 0xd2, 0x6e, 0x69],
69 | 'binary'
70 | )
71 | export const decodeLyric = (str: string) =>
72 | new Promise((resolve, reject) => {
73 | if (!str.length) return
74 | const bufStr = Buffer.from(str, 'base64').slice(4)
75 | for (let i = 0, len = bufStr.length; i < len; i++) {
76 | bufStr[i] = bufStr[i] ^ encKey[i % 16]
77 | }
78 | inflate(bufStr, (err, result) => {
79 | if (err) return reject(err)
80 | resolve(result.toString())
81 | })
82 | })
83 |
84 | const headExp = /^.*\[id:\$\w+\]\n/
85 |
86 | const encodeNames = {
87 | ' ': ' ',
88 | '&': '&',
89 | '<': '<',
90 | '>': '>',
91 | '"': '"',
92 | ''': "'",
93 | ''': "'",
94 | }
95 |
96 | const decodeName = (str = '') =>
97 | // @ts-ignore
98 | str?.replace(/(?:&|<|>|"|'|'| )/gm, (s) => encodeNames[s]) || ''
99 |
100 | export const parseLyric = (str: string) => {
101 | str = str.replace(/\r/g, '')
102 | if (headExp.test(str)) str = str.replace(headExp, '')
103 | const trans = str.match(/\[language:([\w=\\/+]+)\]/)
104 | let lyric
105 | let tlyric: any
106 | if (trans) {
107 | str = str.replace(/\[language:[\w=\\/+]+\]\n/, '')
108 | const json = JSON.parse(Buffer.from(trans[1], 'base64').toString())
109 | for (const item of json.content) {
110 | if (item.type == 1) {
111 | tlyric = item.lyricContent
112 | break
113 | }
114 | }
115 | }
116 | let i = 0
117 | let rlyric = str.replace(/\[((\d+),\d+)\].*/g, (str) => {
118 | const result = str.match(/\[((\d+),\d+)\].*/)
119 | let time = parseInt(result?.[2] as string)
120 | const ms = time % 1000
121 | time /= 1000
122 | const m = parseInt(String(time / 60))
123 | .toString()
124 | .padStart(2, '0')
125 | time %= 60
126 | const s = parseInt(String(time)).toString().padStart(2, '0')
127 | const newTime = `${m}:${s}.${ms}`
128 | if (tlyric) tlyric[i] = `[${newTime}]${tlyric[i++][0]}`
129 | return str.replace(result?.[1] as string, newTime)
130 | })
131 | tlyric = tlyric ? tlyric.join('\n') : ''
132 | rlyric = rlyric.replace(/<(\d+,\d+),\d+>/g, '<$1>')
133 | rlyric = decodeName(rlyric)
134 | lyric = rlyric.replace(/<\d+,\d+>/g, '')
135 | tlyric = decodeName(tlyric)
136 | return {
137 | lyric,
138 | rlyric,
139 | tlyric,
140 | }
141 | }
142 |
143 | const aesEncrypt = (
144 | buffer: Buffer | BinaryLike,
145 | mode: string,
146 | key: Buffer | CipherKey,
147 | iv: Buffer | BinaryLike
148 | ) => {
149 | const cipher = createCipheriv(mode, key, iv)
150 | return Buffer.concat([cipher.update(buffer), cipher.final()])
151 | }
152 |
153 | const rsaEncrypt = (buffer: any, key: string) => {
154 | buffer = Buffer.concat([Buffer.alloc(128 - buffer.length), buffer])
155 | return publicEncrypt({ key, padding: constants.RSA_NO_PADDING }, buffer)
156 | }
157 |
158 | export const encryptParams = (object: { c: string; ids: string }) => {
159 | const text = JSON.stringify(object)
160 | const secretKey = randomBytes(16).map((n) => base62.charAt(n % 62).charCodeAt(0))
161 | return {
162 | params: aesEncrypt(
163 | Buffer.from(aesEncrypt(Buffer.from(text), 'aes-128-cbc', presetKey, iv).toString('base64')),
164 | 'aes-128-cbc',
165 | secretKey,
166 | iv
167 | ).toString('base64'),
168 | encSecKey: rsaEncrypt(secretKey.reverse(), publicKey).toString('hex'),
169 | }
170 | }
171 |
172 | export const convertToStandardTime = (timeStr: string) => {
173 | const timeInSec = parseFloat(timeStr)
174 | const hours = Math.floor(timeInSec / 3600)
175 | const minutes = Math.floor((timeInSec - hours * 3600) / 60)
176 | const seconds = Math.floor(timeInSec - hours * 3600 - minutes * 60)
177 | const milliseconds = Math.round((timeInSec - Math.floor(timeInSec)) * 100)
178 |
179 | const minutesStr = minutes.toString().padStart(2, '0')
180 | const secondsStr = seconds.toString().padStart(2, '0')
181 | const millisecondsStr = milliseconds.toString().padStart(2, '0')
182 |
183 | return `${minutesStr}:${secondsStr}.${millisecondsStr}`
184 | }
185 |
186 | export const streamToString = async (stream: Request) => {
187 | const chunks = []
188 | for await (const chunk of stream) {
189 | chunks.push(Buffer.from(chunk))
190 | }
191 | return Buffer.concat(chunks).toString('utf-8')
192 | }
193 |
--------------------------------------------------------------------------------
/template/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | Music Player
7 |
8 |
9 |
13 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
41 |
46 |
52 |
53 |
54 | {{ item.name }}
55 |
56 |
57 |
58 |
66 |
67 |
68 |
69 |
70 |
71 |
208 |
209 |
210 |
--------------------------------------------------------------------------------
/test/download.test.js:
--------------------------------------------------------------------------------
1 | import { join } from 'node:path'
2 | import { unlink } from 'node:fs/promises'
3 | import { expect, describe, it, beforeEach, afterEach } from 'vitest'
4 | import { pathExists } from 'path-exists'
5 | import { globby } from 'globby'
6 | import { getExecOutput } from '@actions/exec'
7 | import Checkbox from 'inquirer/lib/prompts/checkbox'
8 | import ReadlineStub from './helpers/readline'
9 | import searchMusic from '../src/searchMusic.ts'
10 | import download from '../src/download.ts'
11 | import names from '../src/names.ts'
12 |
13 | describe('download', () => {
14 | this.args = 'my love'
15 | beforeEach(async () => {
16 | this.rl = new ReadlineStub()
17 | const { stdout } = await getExecOutput(`esno src/command.ts ${this.args}`)
18 | const { searchSongs, options } = await searchMusic(JSON.parse(stdout))
19 | this.checkbox = new Checkbox(
20 | {
21 | name: 'songs',
22 | message: '选择歌曲',
23 | choices: searchSongs.map((song, index) => names(song, index, options)),
24 | },
25 | this.rl,
26 | ''
27 | )
28 | })
29 | afterEach(async () => {
30 | const paths = await globby('./*.{flac,mp3,lrc}')
31 | await Promise.all(paths.map((path) => unlink(path)))
32 | })
33 |
34 | const downloadSingleSong = async () => {
35 | const promise = this.checkbox.run()
36 | this.rl.input.emit('keypress', ' ', { name: 'space' })
37 | this.rl.emit('line')
38 | const answer = await promise
39 | const { songName } = answer[0]
40 | expect(answer.length).toEqual(1)
41 | await download(answer)
42 | expect(pathExists(join(process.cwd(), songName))).toBeTruthy()
43 | }
44 |
45 | const downloadTwoSongs = async () => {
46 | const promise = this.checkbox.run()
47 | this.rl.input.emit('keypress', null, { name: 'down' })
48 | this.rl.input.emit('keypress', ' ', { name: 'space' })
49 | this.rl.input.emit('keypress', null, { name: 'down' })
50 | this.rl.input.emit('keypress', ' ', { name: 'space' })
51 | this.rl.emit('line')
52 | const answer = await promise
53 | const { songName: name1 } = answer[0]
54 | const { songName: name2 } = answer[1]
55 | expect(answer.length).toEqual(2)
56 | await download(answer)
57 | expect(pathExists(join(process.cwd(), name1))).toBeTruthy()
58 | expect(pathExists(join(process.cwd(), name2))).toBeTruthy()
59 | }
60 |
61 | const downloadSingleSongInNewDir = async () => {
62 | const promise = this.checkbox.run()
63 | this.rl.input.emit('keypress', ' ', { name: 'space' })
64 | this.rl.emit('line')
65 | const answer = await promise
66 | const {
67 | songName,
68 | options: { path: destDir },
69 | } = answer[0]
70 | expect(answer.length).toEqual(1)
71 | await download(answer)
72 | expect(pathExists(join(destDir, songName))).toBeTruthy()
73 | }
74 |
75 | const downloadSingleSongWithLyric = async () => {
76 | const promise = this.checkbox.run()
77 | this.rl.input.emit('keypress', ' ', { name: 'space' })
78 | this.rl.emit('line')
79 | const answer = await promise
80 | const { songName } = answer[0]
81 | const lrcName = `${songName.split('.')[0]}.lrc`
82 | expect(answer.length).toEqual(1)
83 | await download(answer)
84 | expect(pathExists(join(process.cwd(), songName))).toBeTruthy()
85 | expect(pathExists(join(process.cwd(), lrcName))).toBeTruthy()
86 | }
87 |
88 | it('should download a single song from the default page 1 of the migu service', async () => {
89 | await downloadSingleSong()
90 | })
91 |
92 | it('should download two songs from the default page 1 of the migu service', async () => {
93 | await downloadTwoSongs()
94 | this.args = 'my love -n 2'
95 | })
96 |
97 | it('should download a single song from the page 2 of the migu service', async () => {
98 | await downloadSingleSong()
99 | this.args = 'my love -n 3 -p ./test'
100 | })
101 |
102 | it('should download a single song in a new dir of the migu service', async () => {
103 | await downloadSingleSongInNewDir
104 | this.args = 'my love -n 4 -l'
105 | })
106 |
107 | it('should download a single song with lyric of the migu service', async () => {
108 | await downloadSingleSongWithLyric()
109 | })
110 | })
111 |
--------------------------------------------------------------------------------
/test/helpers/readline.js:
--------------------------------------------------------------------------------
1 | import { EventEmitter } from 'node:events'
2 | import { inherits } from 'node:util'
3 | import sinon from 'sinon'
4 |
5 | const stub = {}
6 |
7 | Object.assign(stub, {
8 | write: sinon.stub().returns(stub),
9 | moveCursor: sinon.stub().returns(stub),
10 | setPrompt: sinon.stub().returns(stub),
11 | close: sinon.stub().returns(stub),
12 | pause: sinon.stub().returns(stub),
13 | resume: sinon.stub().returns(stub),
14 | _getCursorPos: sinon.stub().returns({ cols: 0, rows: 0 }),
15 | output: {
16 | end: sinon.stub(),
17 | mute: sinon.stub(),
18 | unmute: sinon.stub(),
19 | __raw__: '',
20 | write(str) {
21 | this.__raw__ += str
22 | },
23 | },
24 | })
25 |
26 | const ReadlineStub = function () {
27 | this.line = ''
28 | this.input = new EventEmitter()
29 | EventEmitter.apply(this, arguments)
30 | }
31 |
32 | inherits(ReadlineStub, EventEmitter)
33 | Object.assign(ReadlineStub.prototype, stub)
34 |
35 | export default ReadlineStub
36 |
--------------------------------------------------------------------------------
/test/utils.test.js:
--------------------------------------------------------------------------------
1 | import { expect, describe, it } from 'vitest'
2 | import * as utils from '../src/utils/index.ts'
3 |
4 | describe('utils', () => {
5 | it('should remove special characters from the string', () => {
6 | expect(utils.removePunctuation('**you are not alone./')).toEqual('you are not alone')
7 | })
8 |
9 | it('should combine the name property in the array object into a name string', () => {
10 | expect(
11 | utils.joinSingersName([{ name: '周杰伦' }, { name: '蔡依林' }, { name: '侯佩岑' }])
12 | ).toEqual('周杰伦,蔡依林,侯佩岑')
13 | })
14 |
15 | it('should get the size of the song from the url', async () => {
16 | const size = await utils.getSongSizeByUrl(
17 | 'https://freetyst.nf.migu.cn/public/product9th/product43/2021/04/1416/2021年04月13日17点19分内容准入海蝶时代数码1首/标清高清/MP3_128_16_Stero/6008460PLB0165742.mp3'
18 | )
19 | expect(size).toEqual(3416192)
20 | })
21 | })
22 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es2018",
4 | "module": "esnext",
5 | "lib": ["esnext"],
6 | "moduleResolution": "node",
7 | "esModuleInterop": true,
8 | "strict": true,
9 | "strictNullChecks": true,
10 | "resolveJsonModule": true,
11 | "skipLibCheck": true,
12 | "skipDefaultLibCheck": true
13 | },
14 | "exclude": ["node_modules", "dist", "test"]
15 | }
16 |
--------------------------------------------------------------------------------
/tsup.config.ts:
--------------------------------------------------------------------------------
1 | import { readFile } from 'node:fs/promises'
2 | import { defineConfig } from 'tsup'
3 |
4 | const removeSomeCodePlugin = {
5 | name: 'removeSomeCodePlugin',
6 | // @ts-ignore
7 | setup(build) {
8 | build.onLoad({ filter: /command.ts/ }, async ({ path }: { path: string }) => {
9 | const sourceCode = await readFile(path, 'utf8')
10 | return {
11 | contents: sourceCode.split('process.stdout.write(JSON.stringify(content))').join(''),
12 | }
13 | })
14 | },
15 | }
16 |
17 | export default defineConfig({
18 | clean: true,
19 | minify: true,
20 | splitting: true,
21 | outDir: 'dist',
22 | format: ['esm'],
23 | entry: ['src/index.ts'],
24 | noExternal: ['pretty-bytes', 'mem'],
25 | esbuildPlugins: [removeSomeCodePlugin],
26 | })
27 |
--------------------------------------------------------------------------------
/vercel.json:
--------------------------------------------------------------------------------
1 | {
2 | "rewrites": [
3 | { "source": "/", "destination": "/api/index" },
4 | { "source": "/search", "destination": "/api/search/index" }
5 | ]
6 | }
7 |
--------------------------------------------------------------------------------
/vitest.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from 'vitest/config'
2 |
3 | export default defineConfig({
4 | test: {
5 | testTimeout: 100000,
6 | hookTimeout: 100000,
7 | },
8 | })
9 |
--------------------------------------------------------------------------------