@sansenjian/qq-music-api 2.0.5 → 2.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/deploy-docs.yml +54 -0
- package/.github/workflows/release.yml +6 -5
- package/.github/workflows/test.yml +70 -0
- package/README.md +59 -74
- package/jest.config.js +29 -0
- package/module/apis/music/getLyric.js +1 -2
- package/module/apis/search/getSearchByKey.js +1 -1
- package/module/apis/singers/getSingerDesc.js +1 -2
- package/module/apis/singers/getSingerStarNum.js +1 -2
- package/package.json +24 -12
- package/routers/context/getMv.js +1 -1
- package/routers/context/getRanks.js +22 -9
- package/routers/context/getSearchByKey.js +2 -1
- package/routers/context/getSongLists.js +1 -1
- package/routers/router.js +43 -23
- package/tests/integration/api/api.test.js +116 -0
- package/tests/integration/middleware/cors.test.js +37 -0
- package/tests/setup/jest.setup.js +46 -0
- package/tests/setup/testUtils.js +106 -0
- package/tests/unit/util/lyricParse.test.js +97 -0
- package/tests/unit/util/request.test.js +66 -0
- package/util/lyricParse.js +11 -8
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
name: Deploy Docs
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
workflow_dispatch:
|
|
7
|
+
|
|
8
|
+
permissions:
|
|
9
|
+
contents: read
|
|
10
|
+
pages: write
|
|
11
|
+
id-token: write
|
|
12
|
+
|
|
13
|
+
concurrency:
|
|
14
|
+
group: pages
|
|
15
|
+
cancel-in-progress: false
|
|
16
|
+
|
|
17
|
+
jobs:
|
|
18
|
+
build:
|
|
19
|
+
runs-on: ubuntu-latest
|
|
20
|
+
steps:
|
|
21
|
+
- name: Checkout
|
|
22
|
+
uses: actions/checkout@v4
|
|
23
|
+
with:
|
|
24
|
+
fetch-depth: 0
|
|
25
|
+
|
|
26
|
+
- name: Setup Node
|
|
27
|
+
uses: actions/setup-node@v4
|
|
28
|
+
with:
|
|
29
|
+
node-version: 20
|
|
30
|
+
|
|
31
|
+
- name: Setup Pages
|
|
32
|
+
uses: actions/configure-pages@v4
|
|
33
|
+
|
|
34
|
+
- name: Install dependencies
|
|
35
|
+
run: npm install
|
|
36
|
+
|
|
37
|
+
- name: Build with VitePress
|
|
38
|
+
run: npm run docs:build
|
|
39
|
+
|
|
40
|
+
- name: Upload artifact
|
|
41
|
+
uses: actions/upload-pages-artifact@v3
|
|
42
|
+
with:
|
|
43
|
+
path: docs/.vitepress/dist
|
|
44
|
+
|
|
45
|
+
deploy:
|
|
46
|
+
environment:
|
|
47
|
+
name: github-pages
|
|
48
|
+
url: ${{ steps.deployment.outputs.page_url }}
|
|
49
|
+
needs: build
|
|
50
|
+
runs-on: ubuntu-latest
|
|
51
|
+
steps:
|
|
52
|
+
- name: Deploy to GitHub Pages
|
|
53
|
+
id: deployment
|
|
54
|
+
uses: actions/deploy-pages@v4
|
|
@@ -10,26 +10,27 @@ jobs:
|
|
|
10
10
|
contents: read
|
|
11
11
|
packages: write
|
|
12
12
|
steps:
|
|
13
|
-
- uses: actions/checkout@
|
|
13
|
+
- uses: actions/checkout@v4
|
|
14
14
|
|
|
15
|
-
# 发布到 NPM Registry
|
|
16
15
|
- name: Setup Node.js for NPM
|
|
17
|
-
uses: actions/setup-node@
|
|
16
|
+
uses: actions/setup-node@v4
|
|
18
17
|
with:
|
|
19
18
|
node-version: 20
|
|
20
19
|
registry-url: 'https://registry.npmjs.org/'
|
|
20
|
+
|
|
21
21
|
- run: npm install
|
|
22
|
+
|
|
22
23
|
- name: Publish to NPM
|
|
23
24
|
run: npm publish --access public
|
|
24
25
|
env:
|
|
25
26
|
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
|
26
27
|
|
|
27
|
-
# 发布到 GitHub Packages
|
|
28
28
|
- name: Setup Node.js for GitHub Packages
|
|
29
|
-
uses: actions/setup-node@
|
|
29
|
+
uses: actions/setup-node@v4
|
|
30
30
|
with:
|
|
31
31
|
node-version: 20
|
|
32
32
|
registry-url: 'https://npm.pkg.github.com'
|
|
33
|
+
|
|
33
34
|
- name: Publish to GitHub Packages
|
|
34
35
|
run: npm publish --access public
|
|
35
36
|
env:
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
name: Test
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [ main, master ]
|
|
6
|
+
pull_request:
|
|
7
|
+
branches: [ main, master ]
|
|
8
|
+
|
|
9
|
+
jobs:
|
|
10
|
+
test:
|
|
11
|
+
runs-on: ubuntu-latest
|
|
12
|
+
|
|
13
|
+
strategy:
|
|
14
|
+
matrix:
|
|
15
|
+
node-version: [20.x, 22.x, 24.x]
|
|
16
|
+
|
|
17
|
+
steps:
|
|
18
|
+
- name: Checkout repository
|
|
19
|
+
uses: actions/checkout@v4
|
|
20
|
+
|
|
21
|
+
- name: Setup Node.js ${{ matrix.node-version }}
|
|
22
|
+
uses: actions/setup-node@v4
|
|
23
|
+
with:
|
|
24
|
+
node-version: ${{ matrix.node-version }}
|
|
25
|
+
|
|
26
|
+
- name: Install dependencies
|
|
27
|
+
run: npm install
|
|
28
|
+
|
|
29
|
+
- name: Run linter
|
|
30
|
+
run: npm run eslint
|
|
31
|
+
continue-on-error: true
|
|
32
|
+
|
|
33
|
+
- name: Run tests
|
|
34
|
+
run: npm test
|
|
35
|
+
|
|
36
|
+
- name: Upload coverage to Codecov
|
|
37
|
+
uses: codecov/codecov-action@v4
|
|
38
|
+
with:
|
|
39
|
+
files: ./coverage/lcov.info
|
|
40
|
+
flags: unittests
|
|
41
|
+
name: codecov-umbrella
|
|
42
|
+
fail_ci_if_error: false
|
|
43
|
+
|
|
44
|
+
build:
|
|
45
|
+
runs-on: ubuntu-latest
|
|
46
|
+
needs: test
|
|
47
|
+
|
|
48
|
+
steps:
|
|
49
|
+
- name: Checkout repository
|
|
50
|
+
uses: actions/checkout@v4
|
|
51
|
+
|
|
52
|
+
- name: Setup Node.js
|
|
53
|
+
uses: actions/setup-node@v4
|
|
54
|
+
with:
|
|
55
|
+
node-version: 20
|
|
56
|
+
|
|
57
|
+
- name: Install dependencies
|
|
58
|
+
run: npm install
|
|
59
|
+
|
|
60
|
+
- name: Build application
|
|
61
|
+
run: npm run docs:build
|
|
62
|
+
|
|
63
|
+
- name: Check build
|
|
64
|
+
run: |
|
|
65
|
+
if [ -d "docs/.vitepress/dist" ]; then
|
|
66
|
+
echo "Build successful"
|
|
67
|
+
else
|
|
68
|
+
echo "Build failed"
|
|
69
|
+
exit 1
|
|
70
|
+
fi
|
package/README.md
CHANGED
|
@@ -13,17 +13,17 @@
|
|
|
13
13
|
</div>
|
|
14
14
|
|
|
15
15
|
> 🍴 本项目 Fork 自 [Rain120/qq-music-api](https://github.com/Rain120/qq-music-api),原项目已停止维护,此版本持续更新中
|
|
16
|
-
|
|
17
|
-
> QQ音乐API koa2 版本, 通过Web网页版请求QQ音乐接口数据, 有问题请提 [issue](https://github.com/sansenjian/qq-music-api/issues)
|
|
18
|
-
|
|
16
|
+
> QQ 音乐 API koa2 版本,通过 Web 网页版请求 QQ 音乐接口数据,有问题请提 [issue](https://github.com/sansenjian/qq-music-api/issues)
|
|
19
17
|
> 当前代码仅供学习,不可做商业用途
|
|
20
18
|
|
|
21
|
-
### API结构图
|
|
19
|
+
### API 结构图
|
|
22
20
|
|
|
23
|
-
>
|
|
21
|
+
> 目前暂时没有时间做登录模块的接口,欢迎各位大佬给我 `PR`, 阿里嘎多
|
|
24
22
|
|
|
25
23
|

|
|
26
24
|
|
|
25
|
+
📖 **详细 API 文档**: [查看完整 API 文档](https://sansenjian.github.io/qq-music-api/)
|
|
26
|
+
|
|
27
27
|
### 环境要求
|
|
28
28
|
|
|
29
29
|
> 本项目采用 `koa2`,需要 Node.js 18.0.0+
|
|
@@ -79,9 +79,9 @@ node app.js
|
|
|
79
79
|
| axios | ^1.6.0 | 修复安全漏洞 CVE-2021-3749 |
|
|
80
80
|
| koa | ^2.15.0 | 框架更新 |
|
|
81
81
|
| koa-bodyparser | ^4.4.0 | 解析器更新 |
|
|
82
|
-
| @koa/router | ^
|
|
82
|
+
| @koa/router | ^15.3.1 | 替代 koa-router |
|
|
83
83
|
| koa-static | ^5.0.0 | 静态文件服务 |
|
|
84
|
-
|
|
|
84
|
+
| date-fns | ^4.1.0 | 日期处理库 (轻量级) |
|
|
85
85
|
|
|
86
86
|
**开发依赖**
|
|
87
87
|
| 依赖 | 版本 | 说明 |
|
|
@@ -94,83 +94,68 @@ node app.js
|
|
|
94
94
|
| @commitlint/* | ^18.0.0 | 提交信息规范 |
|
|
95
95
|
| @babel/* | ^7.23.0 | 编译工具 |
|
|
96
96
|
| nodemon | ^3.0.0 | 开发热重载 |
|
|
97
|
+
| vitepress | ^1.6.4 | 文档工具 (基于 Vite) |
|
|
98
|
+
| vue | ^3.5.29 | 渐进式 JS 框架 |
|
|
97
99
|
|
|
98
100
|
**已移除的依赖**
|
|
99
101
|
- `colors` - 存在安全问题,已用 chalk 替代
|
|
100
|
-
- `moment` - 已用
|
|
102
|
+
- `moment` - 已用 date-fns 替代
|
|
103
|
+
- `dayjs` - 已用 date-fns 替代 (更小的打包体积)
|
|
104
|
+
- `docsify-cli` - 已用 vitepress 替代 (更好的性能和功能)
|
|
101
105
|
- `lodash.get` - 已用原生可选链 `?.` 替代
|
|
102
106
|
- `eslint-plugin-node` - 已用 eslint-plugin-n 替代
|
|
103
107
|
- `eslint-plugin-standard` - 已集成到 eslint-config-standard
|
|
104
108
|
|
|
105
109
|
### 功能特性
|
|
106
110
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
-
|
|
110
|
-
|
|
111
|
-
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
-
|
|
116
|
-
|
|
117
|
-
-
|
|
118
|
-
|
|
119
|
-
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
-
|
|
128
|
-
|
|
129
|
-
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
-
|
|
146
|
-
|
|
147
|
-
-
|
|
148
|
-
|
|
149
|
-
-
|
|
150
|
-
|
|
151
|
-
- [x] 获取搜索热词
|
|
152
|
-
|
|
153
|
-
- [x] 获取关键字搜索提示
|
|
154
|
-
|
|
155
|
-
- [x] 获取搜索结果
|
|
156
|
-
|
|
157
|
-
- [x] 获取首页推荐
|
|
158
|
-
|
|
159
|
-
- [x] 获取排行榜单列表
|
|
160
|
-
|
|
161
|
-
- [x] 获取排行榜单详情
|
|
162
|
-
|
|
163
|
-
- [x] 获取评论信息(cmd代表的意思没太弄明白)
|
|
164
|
-
|
|
165
|
-
- [x] 获取票务信息
|
|
166
|
-
|
|
167
|
-
- [x] 获取歌单详情
|
|
168
|
-
|
|
169
|
-
- [x] 获取歌手列表
|
|
111
|
+
#### 🎵 音乐播放
|
|
112
|
+
- ✅ **歌曲播放链接** - 获取 QQ 音乐歌曲的播放地址
|
|
113
|
+
- ✅ **歌曲与专辑图片** - 获取歌曲封面和专辑 artwork 图片
|
|
114
|
+
- ✅ **歌曲歌词** - 获取歌曲的歌词信息(含翻译)
|
|
115
|
+
- ✅ **MV 播放信息** - 获取 MV 的播放地址和相关信息
|
|
116
|
+
|
|
117
|
+
#### 🎤 歌手相关
|
|
118
|
+
- ✅ **歌手热门歌曲** - 获取歌手的热门歌曲列表
|
|
119
|
+
- ✅ **歌手信息** - 获取歌手的基本资料信息
|
|
120
|
+
- ✅ **相似歌手** - 获取与指定歌手风格相似的其他歌手
|
|
121
|
+
- ✅ **歌手关注数** - 获取歌手的被关注数量信息
|
|
122
|
+
- ✅ **歌手 MV** - 获取歌手的 MV 作品列表
|
|
123
|
+
- ✅ **歌手专辑** - 获取歌手的专辑作品列表
|
|
124
|
+
|
|
125
|
+
#### 📋 歌单相关
|
|
126
|
+
- ✅ **歌单分类** - 获取 QQ 音乐的歌单分类标签
|
|
127
|
+
- ✅ **歌单列表** - 获取指定分类下的歌单列表
|
|
128
|
+
- ✅ **歌单详情** - 获取歌单的详细信息和歌曲列表
|
|
129
|
+
|
|
130
|
+
#### 🔍 搜索功能
|
|
131
|
+
- ✅ **搜索热词** - 获取当前热门搜索关键词
|
|
132
|
+
- ✅ **搜索提示** - 根据关键字获取搜索建议
|
|
133
|
+
- ✅ **搜索结果** - 获取歌曲、歌手、专辑等搜索结果
|
|
134
|
+
|
|
135
|
+
#### 📊 排行榜
|
|
136
|
+
- ✅ **排行榜单** - 获取所有音乐排行榜列表
|
|
137
|
+
- ✅ **榜单详情** - 获取指定排行榜的歌曲列表
|
|
138
|
+
|
|
139
|
+
#### 💿 专辑相关
|
|
140
|
+
- ✅ **专辑信息** - 获取专辑的详细信息
|
|
141
|
+
- ✅ **数字专辑** - 获取数字专辑的售卖信息
|
|
142
|
+
- ✅ **新碟上架** - 获取最新发布的专辑信息
|
|
143
|
+
|
|
144
|
+
#### 🎬 MV 视频
|
|
145
|
+
- ✅ **MV 标签** - 获取 MV 的分类标签
|
|
146
|
+
- ✅ **MV 列表** - 获取 MV 视频列表
|
|
147
|
+
|
|
148
|
+
#### 🔧 其他功能
|
|
149
|
+
- ✅ **自定义 Cookie** - 支持配置自定义 Cookie 信息
|
|
150
|
+
- ✅ **产品下载** - 获取 QQ 音乐数字产品的下载地址
|
|
151
|
+
- ✅ **电台列表** - 获取网络电台节目列表
|
|
152
|
+
- ✅ **票务信息** - 获取音乐演出票务信息
|
|
153
|
+
- ✅ **评论信息** - 获取歌曲、专辑等评论数据
|
|
154
|
+
- ✅ **首页推荐** - 获取 APP 首页推荐内容
|
|
170
155
|
|
|
171
156
|
### 使用文档
|
|
172
157
|
|
|
173
|
-
|
|
158
|
+
使用 `apis` 详见 [文档](https://sansenjian.github.io/qq-music-api/)
|
|
174
159
|
|
|
175
160
|
### 关于项目
|
|
176
161
|
|
|
@@ -178,7 +163,7 @@ node app.js
|
|
|
178
163
|
|
|
179
164
|
[Binaryify/NeteaseCloudMusicApi](https://github.com/Binaryify/NeteaseCloudMusicApi)
|
|
180
165
|
|
|
181
|
-
[Vue2.0开发企业级移动端音乐Web App](https://coding.imooc.com/class/107.html)
|
|
166
|
+
[Vue2.0 开发企业级移动端音乐 Web App](https://coding.imooc.com/class/107.html)
|
|
182
167
|
|
|
183
168
|
**参考内容**
|
|
184
169
|
|
|
@@ -190,7 +175,7 @@ node app.js
|
|
|
190
175
|
|
|
191
176
|
### 项目不足
|
|
192
177
|
|
|
193
|
-
1.
|
|
178
|
+
1. 因为本人没写过 `unit test`, 所以本项目尚未添加 `unit test`, 等有时间再添加;
|
|
194
179
|
|
|
195
180
|
2. 登录获取个人信息等接口都没做
|
|
196
181
|
|
package/jest.config.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
module.exports = {
|
|
2
|
+
testEnvironment: 'node',
|
|
3
|
+
testMatch: ['**/tests/**/*.test.js'],
|
|
4
|
+
collectCoverageFrom: [
|
|
5
|
+
'module/**/*.js',
|
|
6
|
+
'routers/**/*.js',
|
|
7
|
+
'util/**/*.js',
|
|
8
|
+
'!**/node_modules/**',
|
|
9
|
+
'!**/tests/**'
|
|
10
|
+
],
|
|
11
|
+
coverageDirectory: 'coverage',
|
|
12
|
+
coverageReporters: ['text', 'lcov', 'html'],
|
|
13
|
+
coverageThreshold: {
|
|
14
|
+
global: {
|
|
15
|
+
branches: 50,
|
|
16
|
+
functions: 50,
|
|
17
|
+
lines: 50,
|
|
18
|
+
statements: 50
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
setupFilesAfterEnv: ['<rootDir>/tests/setup/jest.setup.js'],
|
|
22
|
+
testTimeout: 10000,
|
|
23
|
+
verbose: true,
|
|
24
|
+
moduleNameMapper: {
|
|
25
|
+
'^@module/(.*)$': '<rootDir>/module/$1',
|
|
26
|
+
'^@routers/(.*)$': '<rootDir>/routers/$1',
|
|
27
|
+
'^@util/(.*)$': '<rootDir>/util/$1'
|
|
28
|
+
}
|
|
29
|
+
};
|
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
const { lyricParse } = require('../../../util/lyricParse');
|
|
2
|
-
const dayjs = require('dayjs');
|
|
3
2
|
const y_common = require('../y_common');
|
|
4
3
|
|
|
5
4
|
module.exports = ({ method = 'get', params = {}, option = {}, isFormat = false }) => {
|
|
6
5
|
const data = Object.assign(params, {
|
|
7
6
|
format: 'json',
|
|
8
7
|
outCharset: 'utf-8',
|
|
9
|
-
pcachetime:
|
|
8
|
+
pcachetime: Date.now(),
|
|
10
9
|
});
|
|
11
10
|
const options = Object.assign(option, {
|
|
12
11
|
params: data,
|
|
@@ -6,7 +6,7 @@ module.exports = ({ method = 'get', params = {}, option = {} }) => {
|
|
|
6
6
|
outCharset: 'utf-8',
|
|
7
7
|
ct: 24,
|
|
8
8
|
qqmusic_ver: 1298,
|
|
9
|
-
// https://github.com/
|
|
9
|
+
// https://github.com/sansenjian/qq-music-api/issues/68
|
|
10
10
|
// new_json: 1,
|
|
11
11
|
remoteplace: 'txt.yqq.song',
|
|
12
12
|
// searchid: 58932895599763136,
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
const dayjs = require('dayjs');
|
|
2
1
|
const y_common = require('../y_common');
|
|
3
2
|
|
|
4
3
|
module.exports = ({ method = 'get', params = {}, option = {} }) => {
|
|
@@ -7,7 +6,7 @@ module.exports = ({ method = 'get', params = {}, option = {} }) => {
|
|
|
7
6
|
format: 'xml',
|
|
8
7
|
outCharset: 'utf-8',
|
|
9
8
|
utf8: 1,
|
|
10
|
-
r:
|
|
9
|
+
r: Date.now(),
|
|
11
10
|
});
|
|
12
11
|
const options = Object.assign(option, {
|
|
13
12
|
params: data,
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
const dayjs = require('dayjs');
|
|
2
1
|
const y_common = require('../y_common');
|
|
3
2
|
|
|
4
3
|
module.exports = ({ method = 'get', params = {}, option = {} }) => {
|
|
@@ -6,7 +5,7 @@ module.exports = ({ method = 'get', params = {}, option = {} }) => {
|
|
|
6
5
|
format: 'json',
|
|
7
6
|
outCharset: 'utf-8',
|
|
8
7
|
utf8: 1,
|
|
9
|
-
rnd:
|
|
8
|
+
rnd: Date.now(),
|
|
10
9
|
});
|
|
11
10
|
const options = Object.assign(option, {
|
|
12
11
|
params: data,
|
package/package.json
CHANGED
|
@@ -1,15 +1,21 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sansenjian/qq-music-api",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.1.1",
|
|
4
4
|
"publishConfig": {
|
|
5
5
|
"access": "public"
|
|
6
6
|
},
|
|
7
7
|
"description": "QQ Music API - QQ音乐API koa2实现",
|
|
8
8
|
"main": "index.js",
|
|
9
9
|
"scripts": {
|
|
10
|
-
"dev": "nodemon app.js & npm run docs",
|
|
10
|
+
"dev": "nodemon app.js & npm run docs:dev",
|
|
11
11
|
"start": "node app.js",
|
|
12
|
-
"
|
|
12
|
+
"test": "jest",
|
|
13
|
+
"test:watch": "jest --watch",
|
|
14
|
+
"test:coverage": "jest --coverage",
|
|
15
|
+
"test:verbose": "jest --verbose",
|
|
16
|
+
"docs:dev": "vitepress dev docs --port 9611",
|
|
17
|
+
"docs:build": "vitepress build docs",
|
|
18
|
+
"docs:preview": "vitepress preview docs",
|
|
13
19
|
"commit-push": "./scripts/commit-push.sh",
|
|
14
20
|
"changelog": "conventional-changelog -p angular -i CHANGELOG.md -s -r 0",
|
|
15
21
|
"eslint": "eslint --fix --color module/**/** routers/**/** util/**/**",
|
|
@@ -43,11 +49,11 @@
|
|
|
43
49
|
]
|
|
44
50
|
},
|
|
45
51
|
"dependencies": {
|
|
46
|
-
"@koa/router": "^
|
|
47
|
-
"axios": "^1.6
|
|
48
|
-
"
|
|
52
|
+
"@koa/router": "^15.3.1",
|
|
53
|
+
"axios": "^1.13.6",
|
|
54
|
+
"date-fns": "^4.1.0",
|
|
49
55
|
"koa": "^2.15.0",
|
|
50
|
-
"koa-bodyparser": "^4.4.
|
|
56
|
+
"koa-bodyparser": "^4.4.1",
|
|
51
57
|
"koa-static": "^5.0.0"
|
|
52
58
|
},
|
|
53
59
|
"devDependencies": {
|
|
@@ -56,20 +62,26 @@
|
|
|
56
62
|
"@babel/register": "^7.23.0",
|
|
57
63
|
"@commitlint/cli": "^18.0.0",
|
|
58
64
|
"@commitlint/config-conventional": "^18.0.0",
|
|
65
|
+
"@types/jest": "^30.0.0",
|
|
66
|
+
"@types/supertest": "^7.2.0",
|
|
59
67
|
"chalk": "^4.1.0",
|
|
60
68
|
"conventional-changelog-cli": "^4.0.0",
|
|
61
|
-
"
|
|
62
|
-
"eslint": "^8.56.0",
|
|
69
|
+
"eslint": "^8.57.0",
|
|
63
70
|
"eslint-config-standard": "^17.0.0",
|
|
64
71
|
"eslint-plugin-import": "^2.29.0",
|
|
65
72
|
"eslint-plugin-n": "^16.0.0",
|
|
66
73
|
"eslint-plugin-promise": "^6.0.0",
|
|
67
74
|
"husky": "^9.0.0",
|
|
75
|
+
"jest": "^30.2.0",
|
|
68
76
|
"lint-staged": "^15.0.0",
|
|
69
|
-
"nodemon": "^3.
|
|
70
|
-
"prettier": "^3.
|
|
77
|
+
"nodemon": "^3.1.14",
|
|
78
|
+
"prettier": "^3.8.1",
|
|
79
|
+
"sinon": "^21.0.1",
|
|
80
|
+
"supertest": "^7.2.2",
|
|
81
|
+
"vitepress": "^1.6.4",
|
|
82
|
+
"vue": "^3.5.29"
|
|
71
83
|
},
|
|
72
84
|
"engines": {
|
|
73
|
-
"node": ">=
|
|
85
|
+
"node": ">=20.0.0"
|
|
74
86
|
}
|
|
75
87
|
}
|
package/routers/context/getMv.js
CHANGED
|
@@ -2,7 +2,7 @@ const { UCommon } = require('../../module');
|
|
|
2
2
|
|
|
3
3
|
// area_id=15&version_id=7
|
|
4
4
|
module.exports = async (ctx, next) => {
|
|
5
|
-
// BUGFIX: https://github.com/
|
|
5
|
+
// BUGFIX: https://github.com/sansenjian/qq-music-api/issues/16#issuecomment-638230301
|
|
6
6
|
const { area_id = 15, version_id = 7, limit = 20, page = 0 } = ctx.query;
|
|
7
7
|
const start = (+page ? +page - 1 : 0) * +limit;
|
|
8
8
|
const data = {
|
|
@@ -1,21 +1,34 @@
|
|
|
1
1
|
const { UCommon } = require('../../module');
|
|
2
2
|
const { commonParams } = require('../../module/config');
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
3
|
+
|
|
4
|
+
// 缓存 date-fns 模块(避免每次请求都导入)
|
|
5
|
+
let dateFnsCache = null;
|
|
6
|
+
async function getDateFns() {
|
|
7
|
+
if (!dateFnsCache) {
|
|
8
|
+
dateFnsCache = await import('date-fns');
|
|
9
|
+
}
|
|
10
|
+
return dateFnsCache;
|
|
11
|
+
}
|
|
8
12
|
|
|
9
13
|
module.exports = async (ctx, next) => {
|
|
10
|
-
//
|
|
14
|
+
// 异步加载 date-fns (ESM 模块,带缓存)
|
|
15
|
+
const { getISOWeekYear, getISOWeek, parseISO } = await getDateFns();
|
|
16
|
+
|
|
17
|
+
// Desc: https://github.com/sansenjian/qq-music-api/issues/14
|
|
11
18
|
// 1. topId is useless
|
|
12
19
|
// 2. qq api period is change not YYYY-MM-DD
|
|
13
20
|
const topId = +ctx.query.topId || 4;
|
|
14
21
|
const num = +ctx.query.limit || 20;
|
|
15
22
|
const offset = +ctx.query.page || 0;
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
23
|
+
// 支持日期格式:YYYY-MM-DD 或 ISO 8601 格式,无效时自动使用当前日期
|
|
24
|
+
// 使用 parseISO 确保日期字符串被解析为本地时间,避免 UTC 偏移问题
|
|
25
|
+
let date = ctx.query.period ? parseISO(ctx.query.period) : new Date();
|
|
26
|
+
// 验证日期是否有效,无效则使用当前日期
|
|
27
|
+
if (Number.isNaN(date.getTime())) {
|
|
28
|
+
date = new Date();
|
|
29
|
+
}
|
|
30
|
+
const week = getISOWeek(date);
|
|
31
|
+
const isoWeekYearVal = getISOWeekYear(date);
|
|
19
32
|
const period = `${isoWeekYearVal}_${week}`;
|
|
20
33
|
|
|
21
34
|
const data = {
|
|
@@ -5,7 +5,8 @@ const { getSearchByKey } = require('../../module');
|
|
|
5
5
|
// n:每页歌曲数量
|
|
6
6
|
// catZhida: 0表示歌曲, 2表示歌手, 3表示专辑, 4, 5
|
|
7
7
|
module.exports = async (ctx, next) => {
|
|
8
|
-
const
|
|
8
|
+
const w = ctx.query.key || ctx.params.key;
|
|
9
|
+
const { limit: n, page: p, catZhida, remoteplace = 'song' } = ctx.query;
|
|
9
10
|
const props = {
|
|
10
11
|
method: 'get',
|
|
11
12
|
params: {
|
|
@@ -10,7 +10,7 @@ const { songLists } = require('../../module');
|
|
|
10
10
|
*/
|
|
11
11
|
module.exports = async (ctx, next) => {
|
|
12
12
|
const { limit = 20, page = 0, sortId = 5, categoryId = 10000000 } = ctx.query;
|
|
13
|
-
// BUGFIX: https://github.com/
|
|
13
|
+
// BUGFIX: https://github.com/sansenjian/qq-music-api/issues/16
|
|
14
14
|
const sin = +page * +limit;
|
|
15
15
|
const ein = +limit * (+page + 1) - 1;
|
|
16
16
|
const params = Object.assign({
|
package/routers/router.js
CHANGED
|
@@ -11,58 +11,72 @@ router.get('/downloadQQMusic', context.getDownloadQQMusic);
|
|
|
11
11
|
|
|
12
12
|
router.get('/getHotkey', context.getHotKey);
|
|
13
13
|
|
|
14
|
-
|
|
14
|
+
// @deprecated Use query params instead: /getSearchByKey?key=xxx
|
|
15
|
+
// Backward compatible: path param route
|
|
16
|
+
router.get('/getSearchByKey/:key', context.getSearchByKey);
|
|
17
|
+
router.get('/getSearchByKey', context.getSearchByKey);
|
|
15
18
|
|
|
16
19
|
// search smartbox
|
|
17
|
-
|
|
20
|
+
// @deprecated Use query params instead: /getSmartbox?key=xxx
|
|
21
|
+
// Backward compatible: path param route
|
|
22
|
+
router.get('/getSmartbox/:key', context.getSmartbox);
|
|
23
|
+
router.get('/getSmartbox', context.getSmartbox);
|
|
18
24
|
|
|
19
25
|
// 1
|
|
20
26
|
router.get('/getSongListCategories', context.getSongListCategories);
|
|
21
27
|
|
|
22
|
-
|
|
28
|
+
// @deprecated Use query params instead: /getSongLists?page=1&limit=20&categoryId=10000000&sortId=5
|
|
29
|
+
// Backward compatible: positional params route
|
|
30
|
+
router.get('/getSongLists/:page/:limit/:categoryId/:sortId', context.getSongLists);
|
|
31
|
+
router.get('/getSongLists', context.getSongLists);
|
|
23
32
|
|
|
24
33
|
router.post('/batchGetSongLists', context.batchGetSongLists);
|
|
25
34
|
|
|
26
35
|
// getSongInfo
|
|
27
|
-
|
|
36
|
+
// @deprecated Use query params instead: /getSongInfo?songmid=xxx
|
|
37
|
+
// Backward compatible: path param route
|
|
38
|
+
router.get('/getSongInfo/:songmid', context.getSongInfo);
|
|
39
|
+
router.get('/getSongInfo', context.getSongInfo);
|
|
28
40
|
router.post('/batchGetSongInfo', context.batchGetSongInfo);
|
|
29
41
|
|
|
30
42
|
// 4
|
|
31
43
|
// disstid=7011264340
|
|
32
|
-
|
|
44
|
+
// @deprecated Use query params instead: /getSongListDetail?disstid=xxx
|
|
45
|
+
router.get('/getSongListDetail/:disstid', context.getSongListDetail);
|
|
46
|
+
router.get('/getSongListDetail', context.getSongListDetail);
|
|
33
47
|
|
|
34
48
|
// newDisk
|
|
35
|
-
router.get('/getNewDisks
|
|
49
|
+
router.get('/getNewDisks', context.getNewDisks);
|
|
36
50
|
|
|
37
51
|
// getMvByTag
|
|
38
52
|
router.get('/getMvByTag', context.getMvByTag);
|
|
39
53
|
|
|
40
54
|
// MV
|
|
41
55
|
// area_id=15&version_id=7
|
|
42
|
-
router.get('/getMv
|
|
56
|
+
router.get('/getMv', context.getMv);
|
|
43
57
|
|
|
44
58
|
// getSingerList
|
|
45
|
-
router.get('/getSingerList
|
|
59
|
+
router.get('/getSingerList', context.getSingerList);
|
|
46
60
|
|
|
47
61
|
// getSimilarSinger
|
|
48
62
|
// singermid=0025NhlN2yWrP4
|
|
49
|
-
router.get('/getSimilarSinger
|
|
63
|
+
router.get('/getSimilarSinger', context.getSimilarSinger);
|
|
50
64
|
|
|
51
65
|
// getSingerAlbum
|
|
52
66
|
// singermid=0025NhlN2yWrP4
|
|
53
|
-
router.get('/getSingerAlbum
|
|
67
|
+
router.get('/getSingerAlbum', context.getSingerAlbum);
|
|
54
68
|
|
|
55
|
-
router.get('/getSingerHotsong
|
|
69
|
+
router.get('/getSingerHotsong', context.getSingerHotsong);
|
|
56
70
|
|
|
57
71
|
/**
|
|
58
72
|
* @description: getSingerMv
|
|
59
73
|
* @param order: time(fan upload) || listen(singer all)
|
|
60
74
|
*/
|
|
61
|
-
router.get('/getSingerMv
|
|
75
|
+
router.get('/getSingerMv', context.getSingerMv);
|
|
62
76
|
|
|
63
|
-
router.get('/getSingerDesc
|
|
77
|
+
router.get('/getSingerDesc', context.getSingerDesc);
|
|
64
78
|
|
|
65
|
-
router.get('/getSingerStarNum
|
|
79
|
+
router.get('/getSingerStarNum', context.getSingerStarNum);
|
|
66
80
|
|
|
67
81
|
// radio
|
|
68
82
|
router.get('/getRadioLists', context.getRadioLists);
|
|
@@ -73,31 +87,37 @@ router.get('/getDigitalAlbumLists', context.getDigitalAlbumLists);
|
|
|
73
87
|
// music
|
|
74
88
|
// getLyric
|
|
75
89
|
// songmid=003rJSwm3TechU
|
|
76
|
-
|
|
90
|
+
// @deprecated Use query params instead: /getLyric?songmid=xxx
|
|
91
|
+
// Backward compatible: path param route
|
|
92
|
+
router.get('/getLyric/:songmid', context.getLyric);
|
|
93
|
+
router.get('/getLyric', context.getLyric);
|
|
77
94
|
|
|
78
95
|
// songmid=003rJSwm3TechU
|
|
79
|
-
|
|
96
|
+
// @deprecated Use query params instead: /getMusicPlay?songmid=xxx
|
|
97
|
+
// Backward compatible: path param route
|
|
98
|
+
router.get('/getMusicPlay/:songmid', context.getMusicPlay);
|
|
99
|
+
router.get('/getMusicPlay', context.getMusicPlay);
|
|
80
100
|
|
|
81
101
|
// album
|
|
82
102
|
// albummid=0016l2F430zMux
|
|
83
|
-
|
|
103
|
+
// @deprecated Use query params instead: /getAlbumInfo?albummid=xxx
|
|
104
|
+
// Backward compatible: path param route
|
|
105
|
+
router.get('/getAlbumInfo/:albummid', context.getAlbumInfo);
|
|
106
|
+
router.get('/getAlbumInfo', context.getAlbumInfo);
|
|
84
107
|
|
|
85
|
-
router.get(
|
|
86
|
-
'/getComments/:id?/:rootcommentid?/:cid?/:pagesize?/:pagenum?/:cmd?/:reqtype?/:biztype?',
|
|
87
|
-
context.getComments,
|
|
88
|
-
);
|
|
108
|
+
router.get('/getComments', context.getComments);
|
|
89
109
|
|
|
90
110
|
// recommend
|
|
91
111
|
router.get('/getRecommend', context.getRecommend);
|
|
92
112
|
|
|
93
113
|
// mv play
|
|
94
|
-
router.get('/getMvPlay
|
|
114
|
+
router.get('/getMvPlay', context.getMvPlay);
|
|
95
115
|
|
|
96
116
|
// rankList: getTopLists
|
|
97
117
|
router.get('/getTopLists', context.getTopLists);
|
|
98
118
|
|
|
99
119
|
// ranks
|
|
100
|
-
router.get('/getRanks
|
|
120
|
+
router.get('/getRanks', context.getRanks);
|
|
101
121
|
|
|
102
122
|
// ticket
|
|
103
123
|
router.get('/getTicketInfo', context.getTicketInfo);
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
const request = require('supertest');
|
|
2
|
+
const Koa = require('koa');
|
|
3
|
+
const bodyParser = require('koa-bodyparser');
|
|
4
|
+
const router = require('../../../routers/router');
|
|
5
|
+
const cors = require('../../../middlewares/koa-cors');
|
|
6
|
+
|
|
7
|
+
jest.mock('axios', () => {
|
|
8
|
+
const mockFn = jest.fn().mockResolvedValue({ data: { code: 0, data: {} } });
|
|
9
|
+
return {
|
|
10
|
+
get: mockFn,
|
|
11
|
+
post: mockFn,
|
|
12
|
+
GET: mockFn,
|
|
13
|
+
POST: mockFn,
|
|
14
|
+
defaults: {
|
|
15
|
+
withCredentials: true,
|
|
16
|
+
timeout: 10000,
|
|
17
|
+
headers: { post: {} },
|
|
18
|
+
responseType: 'json',
|
|
19
|
+
},
|
|
20
|
+
};
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
const axios = require('axios');
|
|
24
|
+
|
|
25
|
+
function createTestApp() {
|
|
26
|
+
const app = new Koa();
|
|
27
|
+
app.use(cors());
|
|
28
|
+
app.use(bodyParser());
|
|
29
|
+
app.use(router.routes());
|
|
30
|
+
app.use(router.allowedMethods());
|
|
31
|
+
return app;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
describe('API Integration Tests', () => {
|
|
35
|
+
let app;
|
|
36
|
+
let callback;
|
|
37
|
+
|
|
38
|
+
beforeAll(() => {
|
|
39
|
+
app = createTestApp();
|
|
40
|
+
callback = app.callback();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
beforeEach(() => {
|
|
44
|
+
jest.clearAllMocks();
|
|
45
|
+
axios.get.mockResolvedValue({ data: { code: 0, data: {} } });
|
|
46
|
+
axios.post.mockResolvedValue({ data: { code: 0, data: {} } });
|
|
47
|
+
global.userInfo = { cookie: 'test_cookie=123' };
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe('GET /getHotkey', () => {
|
|
51
|
+
test('should return hot search keywords', async () => {
|
|
52
|
+
const response = await request(callback).get('/getHotkey').expect(200);
|
|
53
|
+
expect(response.body).toBeDefined();
|
|
54
|
+
}, 10000);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe('GET /getTopLists', () => {
|
|
58
|
+
test('should return top lists', async () => {
|
|
59
|
+
const response = await request(callback).get('/getTopLists').expect(200);
|
|
60
|
+
expect(response.body).toBeDefined();
|
|
61
|
+
}, 10000);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
describe('GET /getSearchByKey', () => {
|
|
65
|
+
test('should search with query param', async () => {
|
|
66
|
+
const response = await request(callback)
|
|
67
|
+
.get('/getSearchByKey')
|
|
68
|
+
.query({ key: 'test' })
|
|
69
|
+
.expect(200);
|
|
70
|
+
expect(response.body).toBeDefined();
|
|
71
|
+
}, 10000);
|
|
72
|
+
|
|
73
|
+
test('should search with path param (backward compatibility)', async () => {
|
|
74
|
+
const response = await request(callback).get('/getSearchByKey/test').expect(200);
|
|
75
|
+
expect(response.body).toBeDefined();
|
|
76
|
+
}, 10000);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe('GET /getLyric', () => {
|
|
80
|
+
test('should return lyric with query param', async () => {
|
|
81
|
+
const response = await request(callback)
|
|
82
|
+
.get('/getLyric')
|
|
83
|
+
.query({ songmid: 'test123' })
|
|
84
|
+
.expect(200);
|
|
85
|
+
expect(response.body).toBeDefined();
|
|
86
|
+
}, 10000);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
describe('POST /batchGetSongInfo', () => {
|
|
90
|
+
test('should batch get song info', async () => {
|
|
91
|
+
const response = await request(callback)
|
|
92
|
+
.post('/batchGetSongInfo')
|
|
93
|
+
.send({ songmids: 'test1,test2' })
|
|
94
|
+
.expect(200);
|
|
95
|
+
expect(response.body).toBeDefined();
|
|
96
|
+
}, 10000);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
describe('Error handling', () => {
|
|
100
|
+
test('should return 404 for unknown route', async () => {
|
|
101
|
+
await request(callback).get('/unknown-route').expect(404);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test('should return 400 for missing search key', async () => {
|
|
105
|
+
const response = await request(callback).get('/getSearchByKey').expect(400);
|
|
106
|
+
expect(response.body.response).toBe('search key is null');
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
describe('CORS middleware', () => {
|
|
111
|
+
test('should set CORS headers', async () => {
|
|
112
|
+
const response = await request(callback).get('/getHotkey').expect(200);
|
|
113
|
+
expect(response.headers['access-control-allow-origin']).toBeDefined();
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
});
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
const cors = require('../../../middlewares/koa-cors');
|
|
2
|
+
|
|
3
|
+
describe('CORS Middleware', () => {
|
|
4
|
+
test('should set CORS headers', async () => {
|
|
5
|
+
const ctx = {
|
|
6
|
+
method: 'GET',
|
|
7
|
+
set: jest.fn(),
|
|
8
|
+
get: jest.fn().mockReturnValue('http://localhost:3000'),
|
|
9
|
+
vary: jest.fn(),
|
|
10
|
+
status: 200,
|
|
11
|
+
body: {}
|
|
12
|
+
};
|
|
13
|
+
const next = jest.fn().mockResolvedValue(undefined);
|
|
14
|
+
|
|
15
|
+
await cors()(ctx, next);
|
|
16
|
+
|
|
17
|
+
expect(ctx.set).toHaveBeenCalled();
|
|
18
|
+
expect(ctx.vary).toHaveBeenCalledWith('Origin');
|
|
19
|
+
expect(next).toHaveBeenCalled();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test('should handle preflight request', async () => {
|
|
23
|
+
const ctx = {
|
|
24
|
+
method: 'OPTIONS',
|
|
25
|
+
set: jest.fn(),
|
|
26
|
+
get: jest.fn().mockReturnValue('GET'),
|
|
27
|
+
vary: jest.fn(),
|
|
28
|
+
status: 200
|
|
29
|
+
};
|
|
30
|
+
const next = jest.fn().mockResolvedValue(undefined);
|
|
31
|
+
|
|
32
|
+
await cors()(ctx, next);
|
|
33
|
+
|
|
34
|
+
expect(ctx.status).toBe(204);
|
|
35
|
+
expect(ctx.set).toHaveBeenCalledWith('Access-Control-Allow-Methods', expect.any(String));
|
|
36
|
+
});
|
|
37
|
+
});
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// Jest setup file
|
|
2
|
+
// Global setup for all tests
|
|
3
|
+
|
|
4
|
+
// Increase timeout for async operations
|
|
5
|
+
jest.setTimeout(10000);
|
|
6
|
+
|
|
7
|
+
// Mock console methods in tests to reduce noise
|
|
8
|
+
global.console = {
|
|
9
|
+
...console,
|
|
10
|
+
log: jest.fn(),
|
|
11
|
+
debug: jest.fn(),
|
|
12
|
+
info: jest.fn(),
|
|
13
|
+
warn: jest.fn(),
|
|
14
|
+
error: jest.fn()
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
// Global test utilities
|
|
18
|
+
global.testUtils = {
|
|
19
|
+
// Generate random string
|
|
20
|
+
randomString: (length = 10) => {
|
|
21
|
+
return Math.random().toString(36).substring(2, length + 2);
|
|
22
|
+
},
|
|
23
|
+
|
|
24
|
+
// Wait for specified milliseconds
|
|
25
|
+
sleep: (ms) => new Promise(resolve => setTimeout(resolve, ms)),
|
|
26
|
+
|
|
27
|
+
// Create mock response
|
|
28
|
+
mockResponse: (data = {}) => ({
|
|
29
|
+
status: 200,
|
|
30
|
+
data: data,
|
|
31
|
+
json: jest.fn().mockReturnValue(data)
|
|
32
|
+
}),
|
|
33
|
+
|
|
34
|
+
// Create mock request
|
|
35
|
+
mockRequest: (params = {}, query = {}, body = {}) => ({
|
|
36
|
+
params: params,
|
|
37
|
+
query: query,
|
|
38
|
+
body: body,
|
|
39
|
+
headers: {}
|
|
40
|
+
})
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
// Clean up after each test
|
|
44
|
+
afterEach(() => {
|
|
45
|
+
jest.clearAllMocks();
|
|
46
|
+
});
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test utilities for QQ Music API tests
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const fs = require('fs');
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Load test fixture from file
|
|
10
|
+
* @param {string} fixtureName - Name of the fixture file
|
|
11
|
+
* @returns {any} Parsed fixture data
|
|
12
|
+
*/
|
|
13
|
+
function loadFixture(fixtureName) {
|
|
14
|
+
const fixturePath = path.join(__dirname, 'fixtures', fixtureName);
|
|
15
|
+
const data = fs.readFileSync(fixturePath, 'utf8');
|
|
16
|
+
return JSON.parse(data);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Create a mock Koa context
|
|
21
|
+
* @param {Object} options - Context options
|
|
22
|
+
* @returns {Object} Mock context
|
|
23
|
+
*/
|
|
24
|
+
function createMockContext(options = {}) {
|
|
25
|
+
const {
|
|
26
|
+
params = {},
|
|
27
|
+
query = {},
|
|
28
|
+
body = {},
|
|
29
|
+
headers = {},
|
|
30
|
+
method = 'GET'
|
|
31
|
+
} = options;
|
|
32
|
+
|
|
33
|
+
return {
|
|
34
|
+
params,
|
|
35
|
+
query,
|
|
36
|
+
request: {
|
|
37
|
+
body,
|
|
38
|
+
headers,
|
|
39
|
+
method
|
|
40
|
+
},
|
|
41
|
+
body: null,
|
|
42
|
+
status: 200,
|
|
43
|
+
set: jest.fn(),
|
|
44
|
+
get: jest.fn()
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Create a mock next function
|
|
50
|
+
* @returns {Function} Mock next function
|
|
51
|
+
*/
|
|
52
|
+
function createMockNext() {
|
|
53
|
+
return jest.fn().mockResolvedValue(undefined);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Wait for async operations
|
|
58
|
+
* @param {number} ms - Milliseconds to wait
|
|
59
|
+
* @returns {Promise<void>}
|
|
60
|
+
*/
|
|
61
|
+
function sleep(ms) {
|
|
62
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Generate random string
|
|
67
|
+
* @param {number} length - Length of string
|
|
68
|
+
* @returns {string} Random string
|
|
69
|
+
*/
|
|
70
|
+
function randomString(length = 10) {
|
|
71
|
+
return Math.random().toString(36).substring(2, length + 2);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Generate random integer
|
|
76
|
+
* @param {number} min - Minimum value
|
|
77
|
+
* @param {number} max - Maximum value
|
|
78
|
+
* @returns {number} Random integer
|
|
79
|
+
*/
|
|
80
|
+
function randomInt(min, max) {
|
|
81
|
+
return Math.floor(Math.random() * (max - min + 1)) + min;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Assert that response has correct structure
|
|
86
|
+
* @param {Object} response - Response object
|
|
87
|
+
* @param {number} expectedStatus - Expected status code
|
|
88
|
+
*/
|
|
89
|
+
function assertResponseStructure(response, expectedStatus = 200) {
|
|
90
|
+
expect(response).toBeDefined();
|
|
91
|
+
expect(response.status).toBe(expectedStatus);
|
|
92
|
+
if (expectedStatus === 200) {
|
|
93
|
+
expect(response.body).toBeDefined();
|
|
94
|
+
expect(response.body.code).toBeDefined();
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
module.exports = {
|
|
99
|
+
loadFixture,
|
|
100
|
+
createMockContext,
|
|
101
|
+
createMockNext,
|
|
102
|
+
sleep,
|
|
103
|
+
randomString,
|
|
104
|
+
randomInt,
|
|
105
|
+
assertResponseStructure
|
|
106
|
+
};
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
const { lyricParse, Lyric } = require('../../../util/lyricParse');
|
|
2
|
+
|
|
3
|
+
describe('lyricParse', () => {
|
|
4
|
+
describe('Lyric class', () => {
|
|
5
|
+
test('should parse simple lyric', () => {
|
|
6
|
+
const lyricString = `[ti:Song Title]
|
|
7
|
+
[ar:Artist Name]
|
|
8
|
+
[al:Album Name]
|
|
9
|
+
[00:01.00]First line
|
|
10
|
+
[00:05.00]Second line
|
|
11
|
+
[00:10.00]Third line`;
|
|
12
|
+
|
|
13
|
+
const result = lyricParse(lyricString);
|
|
14
|
+
|
|
15
|
+
expect(result).toBeInstanceOf(Lyric);
|
|
16
|
+
expect(result.tags.title).toBe('Song Title');
|
|
17
|
+
expect(result.tags.artist).toBe('Artist Name');
|
|
18
|
+
expect(result.tags.album).toBe('Album Name');
|
|
19
|
+
expect(result.lines).toHaveLength(3);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test('should parse time correctly', () => {
|
|
23
|
+
const lyricString = `[00:01.50]Test line`;
|
|
24
|
+
const result = lyricParse(lyricString);
|
|
25
|
+
|
|
26
|
+
expect(result.lines[0].time).toBe(1500);
|
|
27
|
+
expect(result.lines[0].txt).toBe('Test line');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test('should parse time with milliseconds', () => {
|
|
31
|
+
const lyricString = `[01:23.45]Test line`;
|
|
32
|
+
const result = lyricParse(lyricString);
|
|
33
|
+
|
|
34
|
+
expect(result.lines[0].time).toBe(83450);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test('should handle empty lyric', () => {
|
|
38
|
+
const result = lyricParse('');
|
|
39
|
+
|
|
40
|
+
expect(result.lines).toHaveLength(0);
|
|
41
|
+
expect(result.tags.title).toBe('');
|
|
42
|
+
expect(result.tags.artist).toBe('');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('should sort lines by time', () => {
|
|
46
|
+
const lyricString = `[00:10.00]Third
|
|
47
|
+
[00:01.00]First
|
|
48
|
+
[00:05.00]Second`;
|
|
49
|
+
const result = lyricParse(lyricString);
|
|
50
|
+
|
|
51
|
+
expect(result.lines[0].txt).toBe('First');
|
|
52
|
+
expect(result.lines[1].txt).toBe('Second');
|
|
53
|
+
expect(result.lines[2].txt).toBe('Third');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('should ignore empty lines', () => {
|
|
57
|
+
const lyricString = `[00:01.00]Line 1
|
|
58
|
+
[00:05.00]
|
|
59
|
+
[00:10.00]Line 2`;
|
|
60
|
+
const result = lyricParse(lyricString);
|
|
61
|
+
|
|
62
|
+
expect(result.lines).toHaveLength(2);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('should handle special characters', () => {
|
|
66
|
+
const lyricString = `[00:01.00]测试中文 🎵 Special!@#$%`;
|
|
67
|
+
const result = lyricParse(lyricString);
|
|
68
|
+
|
|
69
|
+
expect(result.lines[0].txt).toBe('测试中文 🎵 Special!@#$%');
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test('should parse offset tag', () => {
|
|
73
|
+
const lyricString = `[offset:500]
|
|
74
|
+
[00:01.00]Test`;
|
|
75
|
+
const result = lyricParse(lyricString);
|
|
76
|
+
|
|
77
|
+
expect(result.tags.offset).toBe('500');
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('should parse by tag', () => {
|
|
81
|
+
const lyricString = `[by:Lyric Creator]
|
|
82
|
+
[00:01.00]Test`;
|
|
83
|
+
const result = lyricParse(lyricString);
|
|
84
|
+
|
|
85
|
+
expect(result.tags.by).toBe('Lyric Creator');
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test('should handle multiple time stamps in one line', () => {
|
|
89
|
+
const lyricString = `[00:01.00][00:05.00]Repeated line`;
|
|
90
|
+
const result = lyricParse(lyricString);
|
|
91
|
+
|
|
92
|
+
expect(result.lines).toHaveLength(2);
|
|
93
|
+
expect(result.lines[0].time).toBe(1000);
|
|
94
|
+
expect(result.lines[1].time).toBe(5000);
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
});
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
const request = require('../../../util/request');
|
|
2
|
+
|
|
3
|
+
jest.mock('axios', () => {
|
|
4
|
+
const mockAxios = {
|
|
5
|
+
GET: jest.fn(),
|
|
6
|
+
POST: jest.fn(),
|
|
7
|
+
get: jest.fn(),
|
|
8
|
+
post: jest.fn(),
|
|
9
|
+
defaults: {
|
|
10
|
+
withCredentials: true,
|
|
11
|
+
timeout: 10000,
|
|
12
|
+
headers: {
|
|
13
|
+
post: {},
|
|
14
|
+
},
|
|
15
|
+
responseType: 'json',
|
|
16
|
+
},
|
|
17
|
+
};
|
|
18
|
+
return mockAxios;
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
const axios = require('axios');
|
|
22
|
+
|
|
23
|
+
describe('request util', () => {
|
|
24
|
+
beforeEach(() => {
|
|
25
|
+
jest.clearAllMocks();
|
|
26
|
+
global.userInfo = { cookie: 'test_cookie=123' };
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
afterEach(() => {
|
|
30
|
+
delete global.userInfo;
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test('should make GET request', async () => {
|
|
34
|
+
const mockData = { code: 0, data: {} };
|
|
35
|
+
axios.GET.mockResolvedValue({ data: mockData });
|
|
36
|
+
|
|
37
|
+
const result = await request('/api/test', 'GET');
|
|
38
|
+
|
|
39
|
+
expect(axios.GET).toHaveBeenCalled();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('should handle request error', async () => {
|
|
43
|
+
const error = new Error('Network Error');
|
|
44
|
+
axios.GET.mockRejectedValue(error);
|
|
45
|
+
|
|
46
|
+
await expect(request('/api/test', 'GET')).rejects.toThrow();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('should set correct headers', async () => {
|
|
50
|
+
axios.GET.mockResolvedValue({ data: {} });
|
|
51
|
+
|
|
52
|
+
await request('/api/test', 'GET', { headers: { 'Custom-Header': 'value' } });
|
|
53
|
+
|
|
54
|
+
const call = axios.GET.mock.calls[0][1];
|
|
55
|
+
expect(call.headers).toBeDefined();
|
|
56
|
+
expect(call.headers.cookies).toBe('test_cookie=123');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('should handle timeout', async () => {
|
|
60
|
+
const error = new Error('Timeout');
|
|
61
|
+
error.code = 'ECONNABORTED';
|
|
62
|
+
axios.GET.mockRejectedValue(error);
|
|
63
|
+
|
|
64
|
+
await expect(request('/api/test', 'GET', { timeout: 5000 })).rejects.toThrow();
|
|
65
|
+
});
|
|
66
|
+
});
|
package/util/lyricParse.js
CHANGED
|
@@ -34,16 +34,19 @@ class Lyric {
|
|
|
34
34
|
_initLines() {
|
|
35
35
|
const lines = this.lyric.split('\n');
|
|
36
36
|
for (let i = 0; i < lines.length; i++) {
|
|
37
|
-
const line = lines[i];
|
|
38
|
-
|
|
39
|
-
|
|
37
|
+
const line = lines[i].trim();
|
|
38
|
+
timeExp.lastIndex = 0;
|
|
39
|
+
const matches = [...line.matchAll(timeExp)];
|
|
40
|
+
if (matches.length > 0) {
|
|
40
41
|
const txt = line.replace(timeExp, '').trim();
|
|
41
|
-
const time = result[1] * 60 * 1000 + result[2] * 1000 + (result[3] || 0) * 10;
|
|
42
42
|
if (txt) {
|
|
43
|
-
|
|
44
|
-
time
|
|
45
|
-
|
|
46
|
-
|
|
43
|
+
for (const result of matches) {
|
|
44
|
+
const time = result[1] * 60 * 1000 + result[2] * 1000 + (result[3] || 0) * 10;
|
|
45
|
+
this.lines.push({
|
|
46
|
+
time,
|
|
47
|
+
txt,
|
|
48
|
+
});
|
|
49
|
+
}
|
|
47
50
|
}
|
|
48
51
|
}
|
|
49
52
|
}
|