@kuriyona/cecilia 0.2.0 → 2.0.0-beta.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/README.md +212 -28
- package/dist/elysia.cjs +288 -0
- package/dist/elysia.d.cts +37 -0
- package/dist/elysia.d.mts +37 -0
- package/dist/elysia.mjs +286 -0
- package/dist/index.cjs +41 -86
- package/dist/index.d.cts +429 -33
- package/dist/index.d.mts +429 -33
- package/dist/index.mjs +2 -83
- package/dist/src-BoyQdkLi.cjs +1360 -0
- package/dist/src-_5V0pZj_.mjs +1121 -0
- package/package.json +40 -4
package/README.md
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# Cecilia
|
|
2
2
|
|
|
3
|
-
网易云音乐 API 的非官方 TypeScript
|
|
3
|
+
网易云音乐 API 的非官方 TypeScript 封装。自行实现 weapi / eapi 加密与传输层(`node:crypto` + 全局 `fetch`),**不支持浏览器、Edge 等运行时,需要 Node ≥ 18**。
|
|
4
|
+
|
|
5
|
+
可选地,通过 `@kuriyona/cecilia/elysia` 子路径可以把这 37 个函数起成一个 HTTP 服务(各一条 `GET` 路由);该子路径需要额外安装 `elysia` + `@elysiajs/cors`,并**只能在 Bun 下监听**。
|
|
4
6
|
|
|
5
7
|
## 安装
|
|
6
8
|
|
|
@@ -11,59 +13,241 @@ pnpm add @kuriyona/cecilia
|
|
|
11
13
|
## 使用
|
|
12
14
|
|
|
13
15
|
```ts
|
|
14
|
-
import {
|
|
16
|
+
import {
|
|
17
|
+
getArtist,
|
|
18
|
+
getPlaylistTracks,
|
|
19
|
+
getSongUrl,
|
|
20
|
+
search,
|
|
21
|
+
} from '@kuriyona/cecilia'
|
|
15
22
|
```
|
|
16
23
|
|
|
17
|
-
|
|
24
|
+
所有函数返回**精简后的领域模型**(统一语义词表:`coverUrl`、`avatarUrl`、`creator`、`duration`(毫秒)等),可选项在原始响应缺失时不会出现。每个函数都接受末位可选的 `options`:
|
|
18
25
|
|
|
19
|
-
|
|
26
|
+
```ts
|
|
27
|
+
interface RequestOptions {
|
|
28
|
+
cookie?: string | Record<string, string> // 登录态,如浏览器复制的含 MUSIC_U 的 cookie
|
|
29
|
+
realIP?: string
|
|
30
|
+
timeout?: number // 默认 10000
|
|
31
|
+
ua?: string
|
|
32
|
+
}
|
|
33
|
+
```
|
|
20
34
|
|
|
21
35
|
```ts
|
|
22
|
-
const
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
// coverImgId: 456,
|
|
27
|
-
// coverImgUrl: 'https://example.com/cover.jpg',
|
|
28
|
-
// userId: 789,
|
|
29
|
-
// createTime: 1000000,
|
|
30
|
-
// songs: [{ id: 1, addTime: 2000000 }, ...]
|
|
31
|
-
// }
|
|
36
|
+
const artists = await search({ keywords: 'HoYo-MiX', type: 100 })
|
|
37
|
+
const playlistId = artists.artists![0].id
|
|
38
|
+
const tracks = await getPlaylistTracks({ id: playlistId, limit: 10 })
|
|
39
|
+
const urls = await getSongUrl({ id: tracks.map((t) => t.id) })
|
|
32
40
|
```
|
|
33
41
|
|
|
34
|
-
###
|
|
42
|
+
### 搜索
|
|
35
43
|
|
|
36
|
-
|
|
44
|
+
```ts
|
|
45
|
+
search(params) // keywords, type?, limit?, offset?
|
|
46
|
+
cloudSearch(params)
|
|
47
|
+
getSearchSuggest(params) // keywords?, mobile?
|
|
48
|
+
getHotSearches()
|
|
49
|
+
getHotSearchDetail()
|
|
50
|
+
getDefaultSearchKeyword()
|
|
51
|
+
searchMultimatch(params)
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
### 歌曲与歌词
|
|
37
55
|
|
|
38
56
|
```ts
|
|
39
|
-
|
|
40
|
-
//
|
|
41
|
-
//
|
|
42
|
-
|
|
43
|
-
//
|
|
57
|
+
getSongsDetail(ids: number[])
|
|
58
|
+
getSongUrl(params) // id, br?
|
|
59
|
+
checkMusic(params) // id, br?
|
|
60
|
+
getLyric(id)
|
|
61
|
+
getLyricNew(id) // 含 wordLines 逐字歌词与 romaLines 罗马音
|
|
62
|
+
getSimilarSongs(id)
|
|
44
63
|
```
|
|
45
64
|
|
|
46
|
-
###
|
|
65
|
+
### 歌单
|
|
47
66
|
|
|
48
|
-
|
|
67
|
+
```ts
|
|
68
|
+
getPlaylistDetail(id)
|
|
69
|
+
getPlaylistTracks(params) // id, limit?, offset?
|
|
70
|
+
getPlaylistDetailDynamic(id)
|
|
71
|
+
getHighQualityTags()
|
|
72
|
+
getTopPlaylists(params)
|
|
73
|
+
getHighQualityPlaylists(params)
|
|
74
|
+
getPlaylistCategories()
|
|
75
|
+
getRelatedPlaylists(id)
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
### 歌手
|
|
79
|
+
|
|
80
|
+
```ts
|
|
81
|
+
getArtist(id)
|
|
82
|
+
getArtistDetail(id)
|
|
83
|
+
getArtistSongs(params)
|
|
84
|
+
getArtistTopSongs(id)
|
|
85
|
+
getArtistAlbums(params)
|
|
86
|
+
getArtistList(params)
|
|
87
|
+
getArtistDesc(id)
|
|
88
|
+
getArtistMvs(params)
|
|
89
|
+
getArtistVideos(params)
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
### 专辑
|
|
49
93
|
|
|
50
94
|
```ts
|
|
95
|
+
getAlbum(id)
|
|
96
|
+
getAlbumProduct(id)
|
|
97
|
+
getAlbumDynamic(id)
|
|
98
|
+
getAlbumSaleBoard(params)
|
|
99
|
+
getAlbumPrivileges(id)
|
|
100
|
+
getAlbumList(params)
|
|
101
|
+
getNewestAlbums()
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
### 示例
|
|
105
|
+
|
|
106
|
+
```ts
|
|
107
|
+
const detail = await getPlaylistDetail(123)
|
|
108
|
+
// {
|
|
109
|
+
// id: 123,
|
|
110
|
+
// name: '歌单名',
|
|
111
|
+
// creatorId: 789,
|
|
112
|
+
// coverUrl: 'https://example.com/cover.jpg',
|
|
113
|
+
// createTime: 1000000,
|
|
114
|
+
// songs: [{ id: 1, addTime: 2000000 }, ...] // 仅曲目 id,全量歌曲用 getPlaylistTracks
|
|
115
|
+
// }
|
|
116
|
+
|
|
51
117
|
const songs = await getSongsDetail([1, 2])
|
|
52
118
|
// [
|
|
53
119
|
// {
|
|
54
120
|
// id: 1,
|
|
55
121
|
// name: 'Song A',
|
|
56
122
|
// artists: [{ id: 10, name: 'Artist 1' }],
|
|
57
|
-
// album: { id: 100, name: 'Album A',
|
|
123
|
+
// album: { id: 100, name: 'Album A', coverUrl: '...' },
|
|
58
124
|
// duration: 200000,
|
|
59
125
|
// },
|
|
60
126
|
// ]
|
|
61
127
|
```
|
|
62
128
|
|
|
129
|
+
## 起一个 HTTP 服务器(可选,Bun)
|
|
130
|
+
|
|
131
|
+
`@kuriyona/cecilia/elysia` 把 37 个函数各暴露成一条 `GET` 路由:既可以直接拿它返回的 Elysia 实例挂进自己的应用,也可以一键起服务。
|
|
132
|
+
|
|
133
|
+
```bash
|
|
134
|
+
pnpm add elysia @elysiajs/cors
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
```ts
|
|
138
|
+
import { createApp, startServer } from '@kuriyona/cecilia/elysia'
|
|
139
|
+
|
|
140
|
+
// 1) 只要现成的 Elysia 实例,自己 .use() / .listen() / 挂到别的实例上
|
|
141
|
+
const app = createApp({ cors: { origin: ['http://localhost:5173'] } })
|
|
142
|
+
|
|
143
|
+
// 2) 或者直接起服务:host/port 默认取 HOST / PORT 环境变量(缺省 127.0.0.1:3000)
|
|
144
|
+
const { app, url } = startServer({
|
|
145
|
+
port: 3000,
|
|
146
|
+
cors: { origin: true },
|
|
147
|
+
auth: { token: process.env.TOKEN! }, // 要求 Authorization: Bearer <token>
|
|
148
|
+
})
|
|
149
|
+
console.log(`listening on ${url}`)
|
|
150
|
+
// 关闭:app.stop()
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
仓库内自带可运行示例:
|
|
154
|
+
|
|
155
|
+
```bash
|
|
156
|
+
PORT=3000 TOKEN=demo bun run examples/elysia-server.ts
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
> `startServer` 依赖 Bun 的 `serve`,在 Node 下会**立即抛错**;`createApp()` 与 `app.handle()` 与运行时无关,也可以在 Node 里自备适配器使用。
|
|
160
|
+
|
|
161
|
+
### 服务器选项
|
|
162
|
+
|
|
163
|
+
| 字段 | 说明 |
|
|
164
|
+
| --- | --- |
|
|
165
|
+
| `host` | 监听地址,默认 `process.env.HOST ?? '127.0.0.1'` |
|
|
166
|
+
| `port` | 监听端口,默认 `process.env.PORT ?? 3000`;`PORT` 非法直接抛错;`port: 0` 取随机端口(返回的 `url` 是实际地址) |
|
|
167
|
+
| `cors` | 原样透传给 `@elysiajs/cors` 的 `CORSConfig`;不传则完全不启用 CORS |
|
|
168
|
+
| `auth` | `{ token }`:所有路由都要求 `Authorization: Bearer <token>`(scheme 大小写不敏感、常量时间比较),失败返回 `401 { code: 401, message: 'Unauthorized' }` |
|
|
169
|
+
|
|
170
|
+
- 入站 `Cookie` 头会**原样转发**给上游,客户端因此可以自带登录态(含 `MUSIC_U`);服务端不保存任何 token。
|
|
171
|
+
- 参数错误:id 列表不合法 → `422`;`type` / `order` / `albumType` 等枚举越界 → `400`;未知路径 → `404`。
|
|
172
|
+
- 上游报错(`NeteaseApiError`)不拦截,走 Elysia 默认错误处理(`500`)。
|
|
173
|
+
|
|
174
|
+
### 路由表
|
|
175
|
+
|
|
176
|
+
| `GET` 路径 | query 参数 | 对应函数 |
|
|
177
|
+
| --- | --- | --- |
|
|
178
|
+
| `/search` | `keywords`(必填)、`type`、`limit`、`offset` | `search` |
|
|
179
|
+
| `/cloudsearch` | 同上 | `cloudSearch` |
|
|
180
|
+
| `/search/suggest` | `keywords`、`mobile` | `getSearchSuggest` |
|
|
181
|
+
| `/search/hot` | — | `getHotSearches` |
|
|
182
|
+
| `/search/hot/detail` | — | `getHotSearchDetail` |
|
|
183
|
+
| `/search/default-keyword` | — | `getDefaultSearchKeyword` |
|
|
184
|
+
| `/search/multimatch` | `keywords`、`type` | `searchMultimatch` |
|
|
185
|
+
| `/song/detail` | `ids`(`1,2,3`) | `getSongsDetail` |
|
|
186
|
+
| `/song/url` | `id`(`1,2`)、`br` | `getSongUrl` |
|
|
187
|
+
| `/lyric` | `id` | `getLyric` |
|
|
188
|
+
| `/lyric/new` | `id` | `getLyricNew` |
|
|
189
|
+
| `/check/music` | `id`、`br` | `checkMusic` |
|
|
190
|
+
| `/simi/song` | `id` | `getSimilarSongs` |
|
|
191
|
+
| `/playlist/detail` | `id` | `getPlaylistDetail` |
|
|
192
|
+
| `/playlist/track/all` | `id`、`limit`、`offset` | `getPlaylistTracks` |
|
|
193
|
+
| `/playlist/detail/dynamic` | `id` | `getPlaylistDetailDynamic` |
|
|
194
|
+
| `/playlist/highquality/tags` | — | `getHighQualityTags` |
|
|
195
|
+
| `/playlist/top` | `cat`、`order`(`hot`/`new`)、`limit`、`offset` | `getTopPlaylists` |
|
|
196
|
+
| `/playlist/highquality/list` | `cat`、`limit`、`before` | `getHighQualityPlaylists` |
|
|
197
|
+
| `/playlist/catalogue` | — | `getPlaylistCategories` |
|
|
198
|
+
| `/playlist/related` | `id` | `getRelatedPlaylists` |
|
|
199
|
+
| `/artist` | `id` | `getArtist` |
|
|
200
|
+
| `/artist/detail` | `id` | `getArtistDetail` |
|
|
201
|
+
| `/artist/songs` | `id`、`order`(`hot`/`time`)、`limit`、`offset` | `getArtistSongs` |
|
|
202
|
+
| `/artist/top/song` | `id` | `getArtistTopSongs` |
|
|
203
|
+
| `/artist/album` | `id`、`limit`、`offset` | `getArtistAlbums` |
|
|
204
|
+
| `/artist/list` | `area`、`type`、`initial`、`limit`、`offset` | `getArtistList` |
|
|
205
|
+
| `/artist/desc` | `id` | `getArtistDesc` |
|
|
206
|
+
| `/artist/mv` | `id`、`limit`、`offset` | `getArtistMvs` |
|
|
207
|
+
| `/artist/video` | `id`、`size`、`cursor`、`order` | `getArtistVideos` |
|
|
208
|
+
| `/album` | `id` | `getAlbum` |
|
|
209
|
+
| `/album/product` | `id` | `getAlbumProduct` |
|
|
210
|
+
| `/album/dynamic` | `id` | `getAlbumDynamic` |
|
|
211
|
+
| `/album/sale/board` | `albumType`(`0`/`1`)、`type`(`daily`/`week`/`year`/`total`)、`year` | `getAlbumSaleBoard` |
|
|
212
|
+
| `/album/privilege` | `id` | `getAlbumPrivileges` |
|
|
213
|
+
| `/album/list` | `area`、`type`、`limit`、`offset` | `getAlbumList` |
|
|
214
|
+
| `/album/new` | — | `getNewestAlbums` |
|
|
215
|
+
|
|
216
|
+
```ts
|
|
217
|
+
// 例:带上自己的登录 cookie 走一遍服务端
|
|
218
|
+
const res = await fetch('http://127.0.0.1:3000/search?keywords=HoYo-MiX&type=100', {
|
|
219
|
+
headers: { cookie: 'MUSIC_U=...' },
|
|
220
|
+
})
|
|
221
|
+
const { artists } = (await res.json()) as SearchResult
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
## 从 0.2 升级
|
|
225
|
+
|
|
226
|
+
`getPlaylistDetail` / `getLyric` / `getSongsDetail` 已改为走加密接口并对齐新词表(破坏性变更):
|
|
227
|
+
|
|
228
|
+
- `PlaylistDetails` → `PlaylistDetail`;`coverImgUrl` → `coverUrl`;`userId` → `creatorId`;删除 `coverImgId`
|
|
229
|
+
- `getSongsDetail` 的 `album.picUrl` → `album.coverUrl`;`SongDetail` 现为 `Song` 的别名
|
|
230
|
+
- `getLyric` 返回值不变
|
|
231
|
+
|
|
232
|
+
## 已知限制
|
|
233
|
+
|
|
234
|
+
- `/song/url`(eapi)免登录可用,但普通 cookie 只返回试听片段(`freeTrialInfo` 会给出截取起止时间),完整/高码率需要会员 cookie;`/song/url/v1`(xeapi)本期未实现。
|
|
235
|
+
- **未实现匿名 token 与易盾反作弊 token(`checkToken` v2/v3)**,也不实现 xeapi。部分接口因此需要自行传入登录 cookie 才能调用。
|
|
236
|
+
- `/simi/artist`(相似歌手)上游返回 `code 301 未登录`(weapi 与明文 api 均如此),本期未纳入导出。
|
|
237
|
+
- `search({ type: 2000 })` 的语音搜索分支依赖上游当前响应结构(`songs` 或 `resources`),结构变化时会抛 `NeteaseApiError` 而不是返回空结果。
|
|
238
|
+
- `getRelatedPlaylists` 抓取 `music.163.com/playlist?id=` 的 HTML;页面结构变化时会抛 `NeteaseApiError`。
|
|
239
|
+
- 上游非 200 的成功码(如 `201`)默认视为失败;`request()` 的 `acceptCodes` 内部参数可按接口放行。
|
|
240
|
+
- `@kuriyona/cecilia/elysia` 的 `startServer` 只在 **Bun** 下可用(Node 下抛错);它不缓存响应、不存 token,也不会注入匿名 token,会员态仍需客户端自带 cookie。
|
|
241
|
+
|
|
63
242
|
## 开发
|
|
64
243
|
|
|
65
244
|
```bash
|
|
66
|
-
pnpm test
|
|
67
|
-
pnpm test:
|
|
68
|
-
pnpm
|
|
69
|
-
|
|
245
|
+
pnpm test # 离线测试(mock fetch + 加密自洽 + 响应整形)
|
|
246
|
+
pnpm test:live # 真实网络测试(含 test/probe.live.test.ts 探针,dump 到 docs/probe/)
|
|
247
|
+
pnpm test:watch # 监听模式
|
|
248
|
+
pnpm build # 构建(dist/{index,elysia}.{mjs,cjs,d.mts,d.cts})
|
|
249
|
+
pnpm clone:api-enhanced # 拉取 api-enhanced 参考实现(仅供比对,运行时不依赖)
|
|
250
|
+
bun run examples/elysia-server.ts # 起 Elysia 示例服务器(需 Bun;PORT / TOKEN 可选)
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
离线测试直接断言各接口的请求 URL、加密方式与整形结果,不需要网络;`pnpm test:live -t probe` 会把上游原始响应写入 `docs/probe/<api>.json`(已 gitignore),用于核对字段路径。
|
package/dist/elysia.cjs
ADDED
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
|
+
const require_src = require("./src-BoyQdkLi.cjs");
|
|
3
|
+
let node_crypto = require("node:crypto");
|
|
4
|
+
let _elysiajs_cors = require("@elysiajs/cors");
|
|
5
|
+
let elysia = require("elysia");
|
|
6
|
+
//#region src/elysia/routes.ts
|
|
7
|
+
/** 入站 Cookie 原样转发给上游,会员态由调用方自带。 */
|
|
8
|
+
const optionsOf = (request) => {
|
|
9
|
+
const cookie = request.headers.get("cookie");
|
|
10
|
+
return cookie === null ? void 0 : { cookie };
|
|
11
|
+
};
|
|
12
|
+
/** 逗号分隔的数字 id 列表,`1,2,3`。 */
|
|
13
|
+
const idListSchema = elysia.t.String({ pattern: "^[0-9]+(,[0-9]+)*$" });
|
|
14
|
+
const idsOf = (value) => value.split(",").map(Number);
|
|
15
|
+
const pagingSchema = {
|
|
16
|
+
limit: elysia.t.Optional(elysia.t.Numeric()),
|
|
17
|
+
offset: elysia.t.Optional(elysia.t.Numeric())
|
|
18
|
+
};
|
|
19
|
+
/** 与 src/types/search.ts 的 SearchType 保持同步。 */
|
|
20
|
+
const SEARCH_TYPES = [
|
|
21
|
+
1,
|
|
22
|
+
10,
|
|
23
|
+
100,
|
|
24
|
+
1e3,
|
|
25
|
+
1002,
|
|
26
|
+
1004,
|
|
27
|
+
1006,
|
|
28
|
+
1009,
|
|
29
|
+
1014,
|
|
30
|
+
2e3
|
|
31
|
+
];
|
|
32
|
+
const searchTypeOf = (value) => value === void 0 ? void 0 : SEARCH_TYPES.find((candidate) => candidate === value);
|
|
33
|
+
/** 白名单外的取值返回 undefined,由调用方回 400。 */
|
|
34
|
+
const pickEnum = (value, allowed) => value === void 0 ? void 0 : allowed.find((candidate) => candidate === value);
|
|
35
|
+
const invalidEnum = (field, allowed) => ({
|
|
36
|
+
code: 400,
|
|
37
|
+
message: `${field} 只支持 ${allowed.join(" / ")}`
|
|
38
|
+
});
|
|
39
|
+
const TOP_PLAYLIST_ORDERS = ["hot", "new"];
|
|
40
|
+
const ARTIST_SONG_ORDERS = ["hot", "time"];
|
|
41
|
+
const ALBUM_SALE_TYPES = [
|
|
42
|
+
"daily",
|
|
43
|
+
"week",
|
|
44
|
+
"year",
|
|
45
|
+
"total"
|
|
46
|
+
];
|
|
47
|
+
const ALBUM_TYPES = ["0", "1"];
|
|
48
|
+
/** Query 里 `albumType` 是字符串,窄化回 0 | 1。 */
|
|
49
|
+
const albumTypeOf = (value) => value === void 0 ? void 0 : value === "0" ? 0 : 1;
|
|
50
|
+
const searchRoutes = (app) => app.get("/search", ({ query, request, set }) => {
|
|
51
|
+
const type = searchTypeOf(query.type);
|
|
52
|
+
if (query.type !== void 0 && type === void 0) {
|
|
53
|
+
set.status = 400;
|
|
54
|
+
return invalidEnum("type", SEARCH_TYPES);
|
|
55
|
+
}
|
|
56
|
+
return require_src.search({
|
|
57
|
+
keywords: query.keywords,
|
|
58
|
+
type,
|
|
59
|
+
limit: query.limit,
|
|
60
|
+
offset: query.offset
|
|
61
|
+
}, optionsOf(request));
|
|
62
|
+
}, { query: elysia.t.Object({
|
|
63
|
+
keywords: elysia.t.String({ minLength: 1 }),
|
|
64
|
+
type: elysia.t.Optional(elysia.t.Numeric()),
|
|
65
|
+
...pagingSchema
|
|
66
|
+
}) }).get("/cloudsearch", ({ query, request, set }) => {
|
|
67
|
+
const type = searchTypeOf(query.type);
|
|
68
|
+
if (query.type !== void 0 && type === void 0) {
|
|
69
|
+
set.status = 400;
|
|
70
|
+
return invalidEnum("type", SEARCH_TYPES);
|
|
71
|
+
}
|
|
72
|
+
return require_src.cloudSearch({
|
|
73
|
+
keywords: query.keywords,
|
|
74
|
+
type,
|
|
75
|
+
limit: query.limit,
|
|
76
|
+
offset: query.offset
|
|
77
|
+
}, optionsOf(request));
|
|
78
|
+
}, { query: elysia.t.Object({
|
|
79
|
+
keywords: elysia.t.String({ minLength: 1 }),
|
|
80
|
+
type: elysia.t.Optional(elysia.t.Numeric()),
|
|
81
|
+
...pagingSchema
|
|
82
|
+
}) }).get("/search/suggest", ({ query, request }) => require_src.getSearchSuggest({
|
|
83
|
+
keywords: query.keywords,
|
|
84
|
+
mobile: query.mobile
|
|
85
|
+
}, optionsOf(request)), { query: elysia.t.Object({
|
|
86
|
+
keywords: elysia.t.Optional(elysia.t.String()),
|
|
87
|
+
mobile: elysia.t.Optional(elysia.t.BooleanString())
|
|
88
|
+
}) }).get("/search/hot", ({ request }) => require_src.getHotSearches(optionsOf(request))).get("/search/hot/detail", ({ request }) => require_src.getHotSearchDetail(optionsOf(request))).get("/search/default-keyword", ({ request }) => require_src.getDefaultSearchKeyword(optionsOf(request))).get("/search/multimatch", ({ query, request, set }) => {
|
|
89
|
+
const type = searchTypeOf(query.type);
|
|
90
|
+
if (query.type !== void 0 && type === void 0) {
|
|
91
|
+
set.status = 400;
|
|
92
|
+
return invalidEnum("type", SEARCH_TYPES);
|
|
93
|
+
}
|
|
94
|
+
return require_src.searchMultimatch({
|
|
95
|
+
keywords: query.keywords,
|
|
96
|
+
type
|
|
97
|
+
}, optionsOf(request));
|
|
98
|
+
}, { query: elysia.t.Object({
|
|
99
|
+
keywords: elysia.t.Optional(elysia.t.String()),
|
|
100
|
+
type: elysia.t.Optional(elysia.t.Numeric())
|
|
101
|
+
}) });
|
|
102
|
+
const songRoutes = (app) => app.get("/song/detail", ({ query, request }) => require_src.getSongsDetail(idsOf(query.ids), optionsOf(request)), { query: elysia.t.Object({ ids: idListSchema }) }).get("/song/url", ({ query, request }) => require_src.getSongUrl({
|
|
103
|
+
id: idsOf(query.id),
|
|
104
|
+
br: query.br
|
|
105
|
+
}, optionsOf(request)), { query: elysia.t.Object({
|
|
106
|
+
id: idListSchema,
|
|
107
|
+
br: elysia.t.Optional(elysia.t.Numeric())
|
|
108
|
+
}) }).get("/lyric", ({ query, request }) => require_src.getLyric(query.id, optionsOf(request)), { query: elysia.t.Object({ id: elysia.t.Numeric() }) }).get("/lyric/new", ({ query, request }) => require_src.getLyricNew(query.id, optionsOf(request)), { query: elysia.t.Object({ id: elysia.t.Numeric() }) }).get("/check/music", ({ query, request }) => require_src.checkMusic({
|
|
109
|
+
id: query.id,
|
|
110
|
+
br: query.br
|
|
111
|
+
}, optionsOf(request)), { query: elysia.t.Object({
|
|
112
|
+
id: elysia.t.Numeric(),
|
|
113
|
+
br: elysia.t.Optional(elysia.t.Numeric())
|
|
114
|
+
}) }).get("/simi/song", ({ query, request }) => require_src.getSimilarSongs(query.id, optionsOf(request)), { query: elysia.t.Object({ id: elysia.t.Numeric() }) });
|
|
115
|
+
const playlistRoutes = (app) => app.get("/playlist/detail", ({ query, request }) => require_src.getPlaylistDetail(query.id, optionsOf(request)), { query: elysia.t.Object({ id: elysia.t.Numeric() }) }).get("/playlist/track/all", ({ query, request }) => require_src.getPlaylistTracks({
|
|
116
|
+
id: query.id,
|
|
117
|
+
limit: query.limit,
|
|
118
|
+
offset: query.offset
|
|
119
|
+
}, optionsOf(request)), { query: elysia.t.Object({
|
|
120
|
+
id: elysia.t.Numeric(),
|
|
121
|
+
...pagingSchema
|
|
122
|
+
}) }).get("/playlist/detail/dynamic", ({ query, request }) => require_src.getPlaylistDetailDynamic(query.id, optionsOf(request)), { query: elysia.t.Object({ id: elysia.t.Numeric() }) }).get("/playlist/highquality/tags", ({ request }) => require_src.getHighQualityTags(optionsOf(request))).get("/playlist/top", ({ query, request, set }) => {
|
|
123
|
+
const order = pickEnum(query.order, TOP_PLAYLIST_ORDERS);
|
|
124
|
+
if (query.order !== void 0 && order === void 0) {
|
|
125
|
+
set.status = 400;
|
|
126
|
+
return invalidEnum("order", TOP_PLAYLIST_ORDERS);
|
|
127
|
+
}
|
|
128
|
+
return require_src.getTopPlaylists({
|
|
129
|
+
cat: query.cat,
|
|
130
|
+
order,
|
|
131
|
+
limit: query.limit,
|
|
132
|
+
offset: query.offset
|
|
133
|
+
}, optionsOf(request));
|
|
134
|
+
}, { query: elysia.t.Object({
|
|
135
|
+
cat: elysia.t.Optional(elysia.t.String()),
|
|
136
|
+
order: elysia.t.Optional(elysia.t.String()),
|
|
137
|
+
...pagingSchema
|
|
138
|
+
}) }).get("/playlist/highquality/list", ({ query, request }) => require_src.getHighQualityPlaylists({
|
|
139
|
+
cat: query.cat,
|
|
140
|
+
limit: query.limit,
|
|
141
|
+
before: query.before
|
|
142
|
+
}, optionsOf(request)), { query: elysia.t.Object({
|
|
143
|
+
cat: elysia.t.Optional(elysia.t.String()),
|
|
144
|
+
limit: elysia.t.Optional(elysia.t.Numeric()),
|
|
145
|
+
before: elysia.t.Optional(elysia.t.Numeric())
|
|
146
|
+
}) }).get("/playlist/catalogue", ({ request }) => require_src.getPlaylistCategories(optionsOf(request))).get("/playlist/related", ({ query, request }) => require_src.getRelatedPlaylists(query.id, optionsOf(request)), { query: elysia.t.Object({ id: elysia.t.Numeric() }) });
|
|
147
|
+
const artistRoutes = (app) => app.get("/artist", ({ query, request }) => require_src.getArtist(query.id, optionsOf(request)), { query: elysia.t.Object({ id: elysia.t.Numeric() }) }).get("/artist/detail", ({ query, request }) => require_src.getArtistDetail(query.id, optionsOf(request)), { query: elysia.t.Object({ id: elysia.t.Numeric() }) }).get("/artist/songs", ({ query, request, set }) => {
|
|
148
|
+
const order = pickEnum(query.order, ARTIST_SONG_ORDERS);
|
|
149
|
+
if (query.order !== void 0 && order === void 0) {
|
|
150
|
+
set.status = 400;
|
|
151
|
+
return invalidEnum("order", ARTIST_SONG_ORDERS);
|
|
152
|
+
}
|
|
153
|
+
return require_src.getArtistSongs({
|
|
154
|
+
id: query.id,
|
|
155
|
+
order,
|
|
156
|
+
limit: query.limit,
|
|
157
|
+
offset: query.offset
|
|
158
|
+
}, optionsOf(request));
|
|
159
|
+
}, { query: elysia.t.Object({
|
|
160
|
+
id: elysia.t.Numeric(),
|
|
161
|
+
order: elysia.t.Optional(elysia.t.String()),
|
|
162
|
+
...pagingSchema
|
|
163
|
+
}) }).get("/artist/top/song", ({ query, request }) => require_src.getArtistTopSongs(query.id, optionsOf(request)), { query: elysia.t.Object({ id: elysia.t.Numeric() }) }).get("/artist/album", ({ query, request }) => require_src.getArtistAlbums({
|
|
164
|
+
id: query.id,
|
|
165
|
+
limit: query.limit,
|
|
166
|
+
offset: query.offset
|
|
167
|
+
}, optionsOf(request)), { query: elysia.t.Object({
|
|
168
|
+
id: elysia.t.Numeric(),
|
|
169
|
+
...pagingSchema
|
|
170
|
+
}) }).get("/artist/list", ({ query, request }) => require_src.getArtistList({
|
|
171
|
+
area: query.area,
|
|
172
|
+
type: query.type,
|
|
173
|
+
initial: query.initial,
|
|
174
|
+
limit: query.limit,
|
|
175
|
+
offset: query.offset
|
|
176
|
+
}, optionsOf(request)), { query: elysia.t.Object({
|
|
177
|
+
area: elysia.t.Optional(elysia.t.Numeric()),
|
|
178
|
+
type: elysia.t.Optional(elysia.t.Numeric()),
|
|
179
|
+
initial: elysia.t.Optional(elysia.t.String()),
|
|
180
|
+
...pagingSchema
|
|
181
|
+
}) }).get("/artist/desc", ({ query, request }) => require_src.getArtistDesc(query.id, optionsOf(request)), { query: elysia.t.Object({ id: elysia.t.Numeric() }) }).get("/artist/mv", ({ query, request }) => require_src.getArtistMvs({
|
|
182
|
+
id: query.id,
|
|
183
|
+
limit: query.limit,
|
|
184
|
+
offset: query.offset
|
|
185
|
+
}, optionsOf(request)), { query: elysia.t.Object({
|
|
186
|
+
id: elysia.t.Numeric(),
|
|
187
|
+
...pagingSchema
|
|
188
|
+
}) }).get("/artist/video", ({ query, request }) => require_src.getArtistVideos({
|
|
189
|
+
id: query.id,
|
|
190
|
+
size: query.size,
|
|
191
|
+
cursor: query.cursor,
|
|
192
|
+
order: query.order
|
|
193
|
+
}, optionsOf(request)), { query: elysia.t.Object({
|
|
194
|
+
id: elysia.t.Numeric(),
|
|
195
|
+
size: elysia.t.Optional(elysia.t.Numeric()),
|
|
196
|
+
cursor: elysia.t.Optional(elysia.t.Numeric()),
|
|
197
|
+
order: elysia.t.Optional(elysia.t.Numeric())
|
|
198
|
+
}) });
|
|
199
|
+
const albumRoutes = (app) => app.get("/album", ({ query, request }) => require_src.getAlbum(query.id, optionsOf(request)), { query: elysia.t.Object({ id: elysia.t.Numeric() }) }).get("/album/product", ({ query, request }) => require_src.getAlbumProduct(query.id, optionsOf(request)), { query: elysia.t.Object({ id: elysia.t.Numeric() }) }).get("/album/dynamic", ({ query, request }) => require_src.getAlbumDynamic(query.id, optionsOf(request)), { query: elysia.t.Object({ id: elysia.t.Numeric() }) }).get("/album/sale/board", ({ query, request, set }) => {
|
|
200
|
+
const albumType = pickEnum(query.albumType, ALBUM_TYPES);
|
|
201
|
+
if (query.albumType !== void 0 && albumType === void 0) {
|
|
202
|
+
set.status = 400;
|
|
203
|
+
return invalidEnum("albumType", ALBUM_TYPES);
|
|
204
|
+
}
|
|
205
|
+
const type = pickEnum(query.type, ALBUM_SALE_TYPES);
|
|
206
|
+
if (query.type !== void 0 && type === void 0) {
|
|
207
|
+
set.status = 400;
|
|
208
|
+
return invalidEnum("type", ALBUM_SALE_TYPES);
|
|
209
|
+
}
|
|
210
|
+
return require_src.getAlbumSaleBoard({
|
|
211
|
+
albumType: albumTypeOf(albumType),
|
|
212
|
+
type,
|
|
213
|
+
year: query.year
|
|
214
|
+
}, optionsOf(request));
|
|
215
|
+
}, { query: elysia.t.Object({
|
|
216
|
+
albumType: elysia.t.Optional(elysia.t.String()),
|
|
217
|
+
type: elysia.t.Optional(elysia.t.String()),
|
|
218
|
+
year: elysia.t.Optional(elysia.t.Numeric())
|
|
219
|
+
}) }).get("/album/privilege", ({ query, request }) => require_src.getAlbumPrivileges(query.id, optionsOf(request)), { query: elysia.t.Object({ id: elysia.t.Numeric() }) }).get("/album/list", ({ query, request }) => require_src.getAlbumList({
|
|
220
|
+
area: query.area,
|
|
221
|
+
type: query.type,
|
|
222
|
+
limit: query.limit,
|
|
223
|
+
offset: query.offset
|
|
224
|
+
}, optionsOf(request)), { query: elysia.t.Object({
|
|
225
|
+
area: elysia.t.Optional(elysia.t.String()),
|
|
226
|
+
type: elysia.t.Optional(elysia.t.String()),
|
|
227
|
+
...pagingSchema
|
|
228
|
+
}) }).get("/album/new", ({ request }) => require_src.getNewestAlbums(optionsOf(request)));
|
|
229
|
+
/** 37 个函数各一条 GET 路由,按 搜索 / 歌曲 / 歌单 / 歌手 / 专辑 依次注册。 */
|
|
230
|
+
const registerRoutes = (app) => albumRoutes(artistRoutes(playlistRoutes(songRoutes(searchRoutes(app)))));
|
|
231
|
+
//#endregion
|
|
232
|
+
//#region src/elysia/app.ts
|
|
233
|
+
/** scheme 大小写不敏感,比较用 timingSafeEqual(长度不等直接 false)。 */
|
|
234
|
+
const isAuthorized = (request, token) => {
|
|
235
|
+
const header = request.headers.get("authorization");
|
|
236
|
+
if (header === null) return false;
|
|
237
|
+
const [scheme, value, ...rest] = header.split(" ");
|
|
238
|
+
if (rest.length > 0 || scheme?.toLowerCase() !== "bearer" || value === void 0) return false;
|
|
239
|
+
const expected = Buffer.from(token, "utf8");
|
|
240
|
+
const actual = Buffer.from(value, "utf8");
|
|
241
|
+
return expected.length === actual.length && (0, node_crypto.timingSafeEqual)(expected, actual);
|
|
242
|
+
};
|
|
243
|
+
/** 中间件风格插件:挂在实例上后,后续注册的路由都要先过鉴权。 */
|
|
244
|
+
const withAuth = (options) => (app) => app.onBeforeHandle(({ request, set }) => {
|
|
245
|
+
if (isAuthorized(request, options.token)) return;
|
|
246
|
+
set.status = 401;
|
|
247
|
+
return {
|
|
248
|
+
code: 401,
|
|
249
|
+
message: "Unauthorized"
|
|
250
|
+
};
|
|
251
|
+
});
|
|
252
|
+
/**
|
|
253
|
+
* 把 37 个 Cecilia 函数各注册成一条 GET 路由,返回可直接 `.use()` 或 `.listen()` 的 Elysia 实例。
|
|
254
|
+
* CORS 先注册(预检早于鉴权短路),再鉴权,最后才是路由。
|
|
255
|
+
*/
|
|
256
|
+
const createApp = (options) => {
|
|
257
|
+
let app = new elysia.Elysia();
|
|
258
|
+
if (options?.cors !== void 0) app = app.use((0, _elysiajs_cors.cors)(options.cors));
|
|
259
|
+
if (options?.auth !== void 0) app = withAuth(options.auth)(app);
|
|
260
|
+
return registerRoutes(app);
|
|
261
|
+
};
|
|
262
|
+
//#endregion
|
|
263
|
+
//#region src/elysia/server.ts
|
|
264
|
+
const readPortEnv = () => {
|
|
265
|
+
const raw = process.env.PORT;
|
|
266
|
+
if (raw === void 0 || raw === "") return 3e3;
|
|
267
|
+
const port = Number(raw);
|
|
268
|
+
if (!Number.isInteger(port) || port < 0 || port > 65535) throw new Error(`PORT 环境变量非法: ${raw}`);
|
|
269
|
+
return port;
|
|
270
|
+
};
|
|
271
|
+
/** 同步返回:Bun 的 listen 是同步的,端口占用会同步抛出。 */
|
|
272
|
+
const startServer = (options) => {
|
|
273
|
+
if (!("Bun" in globalThis)) throw new Error("@kuriyona/cecilia/elysia 的 startServer 需要 Bun 运行时(例如 bun run server.ts);Node 下请用 createApp() 自备适配器。");
|
|
274
|
+
const port = options?.port ?? readPortEnv();
|
|
275
|
+
const host = options?.host ?? process.env.HOST ?? "127.0.0.1";
|
|
276
|
+
const app = createApp(options);
|
|
277
|
+
app.listen({
|
|
278
|
+
hostname: host,
|
|
279
|
+
port
|
|
280
|
+
});
|
|
281
|
+
return {
|
|
282
|
+
app,
|
|
283
|
+
url: app.server?.url.origin ?? `http://${host}:${port}`
|
|
284
|
+
};
|
|
285
|
+
};
|
|
286
|
+
//#endregion
|
|
287
|
+
exports.createApp = createApp;
|
|
288
|
+
exports.startServer = startServer;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { CORSConfig, CORSConfig as CORSConfig$1 } from "@elysiajs/cors";
|
|
2
|
+
import { AnyElysia } from "elysia";
|
|
3
|
+
|
|
4
|
+
//#region src/elysia/app.d.ts
|
|
5
|
+
interface AuthOptions {
|
|
6
|
+
/** 静态 token;请求需带 `Authorization: Bearer <token>`。 */
|
|
7
|
+
token: string;
|
|
8
|
+
}
|
|
9
|
+
interface CeciliaAppOptions {
|
|
10
|
+
/** 透传给 @elysiajs/cors;不传则完全不启用 CORS。 */
|
|
11
|
+
cors?: CORSConfig$1;
|
|
12
|
+
/** 不传则全部路由公开。 */
|
|
13
|
+
auth?: AuthOptions;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* 把 37 个 Cecilia 函数各注册成一条 GET 路由,返回可直接 `.use()` 或 `.listen()` 的 Elysia 实例。
|
|
17
|
+
* CORS 先注册(预检早于鉴权短路),再鉴权,最后才是路由。
|
|
18
|
+
*/
|
|
19
|
+
declare const createApp: (options?: CeciliaAppOptions) => AnyElysia;
|
|
20
|
+
//#endregion
|
|
21
|
+
//#region src/elysia/server.d.ts
|
|
22
|
+
interface CeciliaServerOptions extends CeciliaAppOptions {
|
|
23
|
+
/** 默认 `process.env.HOST ?? "127.0.0.1"`。 */
|
|
24
|
+
host?: string;
|
|
25
|
+
/** 默认 `process.env.PORT ?? 3000`;非法 PORT 直接抛错。 */
|
|
26
|
+
port?: number;
|
|
27
|
+
}
|
|
28
|
+
interface CeciliaServer {
|
|
29
|
+
/** 关闭用 `app.stop()`。 */
|
|
30
|
+
app: AnyElysia;
|
|
31
|
+
/** 形如 `http://127.0.0.1:3000`(取自实际监听地址,`port: 0` 也可用)。 */
|
|
32
|
+
url: string;
|
|
33
|
+
}
|
|
34
|
+
/** 同步返回:Bun 的 listen 是同步的,端口占用会同步抛出。 */
|
|
35
|
+
declare const startServer: (options?: CeciliaServerOptions) => CeciliaServer;
|
|
36
|
+
//#endregion
|
|
37
|
+
export { type AuthOptions, type CORSConfig, type CeciliaAppOptions, type CeciliaServer, type CeciliaServerOptions, createApp, startServer };
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { CORSConfig, CORSConfig as CORSConfig$1 } from "@elysiajs/cors";
|
|
2
|
+
import { AnyElysia } from "elysia";
|
|
3
|
+
|
|
4
|
+
//#region src/elysia/app.d.ts
|
|
5
|
+
interface AuthOptions {
|
|
6
|
+
/** 静态 token;请求需带 `Authorization: Bearer <token>`。 */
|
|
7
|
+
token: string;
|
|
8
|
+
}
|
|
9
|
+
interface CeciliaAppOptions {
|
|
10
|
+
/** 透传给 @elysiajs/cors;不传则完全不启用 CORS。 */
|
|
11
|
+
cors?: CORSConfig$1;
|
|
12
|
+
/** 不传则全部路由公开。 */
|
|
13
|
+
auth?: AuthOptions;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* 把 37 个 Cecilia 函数各注册成一条 GET 路由,返回可直接 `.use()` 或 `.listen()` 的 Elysia 实例。
|
|
17
|
+
* CORS 先注册(预检早于鉴权短路),再鉴权,最后才是路由。
|
|
18
|
+
*/
|
|
19
|
+
declare const createApp: (options?: CeciliaAppOptions) => AnyElysia;
|
|
20
|
+
//#endregion
|
|
21
|
+
//#region src/elysia/server.d.ts
|
|
22
|
+
interface CeciliaServerOptions extends CeciliaAppOptions {
|
|
23
|
+
/** 默认 `process.env.HOST ?? "127.0.0.1"`。 */
|
|
24
|
+
host?: string;
|
|
25
|
+
/** 默认 `process.env.PORT ?? 3000`;非法 PORT 直接抛错。 */
|
|
26
|
+
port?: number;
|
|
27
|
+
}
|
|
28
|
+
interface CeciliaServer {
|
|
29
|
+
/** 关闭用 `app.stop()`。 */
|
|
30
|
+
app: AnyElysia;
|
|
31
|
+
/** 形如 `http://127.0.0.1:3000`(取自实际监听地址,`port: 0` 也可用)。 */
|
|
32
|
+
url: string;
|
|
33
|
+
}
|
|
34
|
+
/** 同步返回:Bun 的 listen 是同步的,端口占用会同步抛出。 */
|
|
35
|
+
declare const startServer: (options?: CeciliaServerOptions) => CeciliaServer;
|
|
36
|
+
//#endregion
|
|
37
|
+
export { type AuthOptions, type CORSConfig, type CeciliaAppOptions, type CeciliaServer, type CeciliaServerOptions, createApp, startServer };
|