├── .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' + 62 | ' \n' + 71 | ' \n' + 72 | '
\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 |
27 | 28 | 33 | 39 | 40 |
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 | --------------------------------------------------------------------------------