@sansenjian/qq-music-api 2.1.0 → 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.
@@ -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@v6
13
+ - uses: actions/checkout@v4
14
14
 
15
- # 发布到 NPM Registry
16
15
  - name: Setup Node.js for NPM
17
- uses: actions/setup-node@v6
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@v6
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
- > 目前暂时没有时间做登录模块的接口,欢迎各位大佬给我`PR`, 阿里嘎多
21
+ > 目前暂时没有时间做登录模块的接口,欢迎各位大佬给我 `PR`, 阿里嘎多
24
22
 
25
23
  ![qq-music](./screenshot/qq-music.png)
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 | ^12.0.0 | 替代 koa-router |
82
+ | @koa/router | ^15.3.1 | 替代 koa-router |
83
83
  | koa-static | ^5.0.0 | 静态文件服务 |
84
- | dayjs | ^1.11.10 | 替代 moment.js (更轻量) |
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` - 已用 dayjs 替代
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
- - [x] 获取歌曲播放链接 **2021-01-24**
108
-
109
- - [x] 支持自定义设置 `cookie` **2021-01-23**
110
-
111
- - [x] 获取歌曲 + 专辑图片 **2020-05-24**
112
-
113
- - [x] 获取歌手热门歌曲 **2020-07-04**
114
-
115
- - [x] 获取QQ音乐产品的下载地址
116
-
117
- - [x] 获取歌单分类
118
-
119
- - [x] 获取歌单列表
120
-
121
- - [x] 获取歌单详情
122
-
123
- - [x] 获取MV标签
124
-
125
- - [x] 获取MV播放信息
126
-
127
- - [x] 获取歌手MV
128
-
129
- - [x] 获取相似歌手
130
-
131
- - [x] 获取歌手信息
132
-
133
- - [x] 获取歌手被关注数量信息
134
-
135
- - [x] 获取电台列表
136
-
137
- - [x] 获取专辑
138
-
139
- - [x] 获取数字专辑
140
-
141
- - [x] 获取歌曲歌词
142
-
143
- - [x] 获取MV
144
-
145
- - [x] 获取新碟信息
146
-
147
- - [x] 获取歌手专辑
148
-
149
- - [x] ~~获取歌曲VKey~~ **2021-01-24**
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
- 使用`apis`详见[文档](https://rain120.github.io/qq-music-api/#/)
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. 因为本人没写过`unit test`, 所以本项目尚未添加`unit test`, 等有时间再添加;
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: dayjs().valueOf(),
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/Rain120/qq-music-api/issues/68
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: dayjs().valueOf(),
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: dayjs().valueOf(),
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.1.0",
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
- "docs": "docsify serve docs -p 9611",
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": "^12.0.0",
47
- "axios": "^1.6.0",
48
- "dayjs": "^1.11.10",
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.0",
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
- "docsify-cli": "^4.4.4",
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.0.0",
70
- "prettier": "^3.0.0"
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": ">=18.0.0"
85
+ "node": ">=20.0.0"
74
86
  }
75
87
  }
@@ -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/Rain120/qq-music-api/issues/16#issuecomment-638230301
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
- const dayjs = require('dayjs');
4
- const isoWeek = require('dayjs/plugin/isoWeek');
5
- const isoWeekYear = require('dayjs/plugin/isoWeekYear');
6
- dayjs.extend(isoWeek);
7
- dayjs.extend(isoWeekYear);
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
- // Desc: https://github.com/Rain120/qq-music-api/issues/14
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
- const date = ctx.query.period || dayjs();
17
- const week = dayjs(date).isoWeek();
18
- const isoWeekYearVal = dayjs(date).isoWeekYear();
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 { key: w, limit: n, page: p, catZhida, remoteplace = 'song' } = ctx.query;
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/Rain120/qq-music-api/issues/16
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
- router.get('/getSearchByKey/:key?/:limit?/:page?/:catZhida?', context.getSearchByKey);
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
- router.get('/getSmartbox/:key?', context.getSmartbox);
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
- router.get('/getSongLists/:page?/:limit?/:categoryId?/:sortId?', context.getSongLists);
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
- router.get('/getSongInfo/:songmid?/:songid?', context.getSongInfo);
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
- router.get('/getSongListDetail/:disstid?', context.getSongListDetail);
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/:page?/:limit?', context.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/:area_id?/:version_id?/:limit?/:page?', context.getMv);
56
+ router.get('/getMv', context.getMv);
43
57
 
44
58
  // getSingerList
45
- router.get('/getSingerList/:area?/:sex?/:genre?/:index?/:page?', context.getSingerList);
59
+ router.get('/getSingerList', context.getSingerList);
46
60
 
47
61
  // getSimilarSinger
48
62
  // singermid=0025NhlN2yWrP4
49
- router.get('/getSimilarSinger/:singermid?', context.getSimilarSinger);
63
+ router.get('/getSimilarSinger', context.getSimilarSinger);
50
64
 
51
65
  // getSingerAlbum
52
66
  // singermid=0025NhlN2yWrP4
53
- router.get('/getSingerAlbum/:singermid?/:limit?/:page?', context.getSingerAlbum);
67
+ router.get('/getSingerAlbum', context.getSingerAlbum);
54
68
 
55
- router.get('/getSingerHotsong/:singermid?/:limit?/:page?', context.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/:singermid?/:limit?/:order?', context.getSingerMv);
75
+ router.get('/getSingerMv', context.getSingerMv);
62
76
 
63
- router.get('/getSingerDesc/:singermid?', context.getSingerDesc);
77
+ router.get('/getSingerDesc', context.getSingerDesc);
64
78
 
65
- router.get('/getSingerStarNum/:singermid?', context.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
- router.get('/getLyric/:songmid?/:isFormat?', context.getLyric);
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
- router.get('/getMusicPlay/:songmid?', context.getMusicPlay);
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
- router.get('/getAlbumInfo/:albummid?', context.getAlbumInfo);
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/:vid?', context.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/:topId?/:limit?/:page?', context.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
+ });
@@ -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
- const result = timeExp.exec(line);
39
- if (result) {
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
- this.lines.push({
44
- time,
45
- txt,
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
  }