@zhin.js/plugin-music 0.0.44 → 0.0.46
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/package.json +9 -5
- package/src/async-jsx.ts +54 -0
- package/src/config.ts +54 -0
- package/src/index.tsx +189 -0
- package/src/sources/index.ts +13 -0
- package/src/sources/netease.ts +84 -0
- package/src/sources/qq.ts +148 -0
- package/src/types.ts +67 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhin.js/plugin-music",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.46",
|
|
4
4
|
"description": "Music search and sharing plugin for Zhin.js",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./lib/index.js",
|
|
@@ -13,7 +13,11 @@
|
|
|
13
13
|
"./package.json": "./package.json"
|
|
14
14
|
},
|
|
15
15
|
"files": [
|
|
16
|
+
"src",
|
|
16
17
|
"lib",
|
|
18
|
+
"client",
|
|
19
|
+
"dist",
|
|
20
|
+
"skills",
|
|
17
21
|
"README.md"
|
|
18
22
|
],
|
|
19
23
|
"keywords": [
|
|
@@ -35,12 +39,12 @@
|
|
|
35
39
|
"dependencies": {},
|
|
36
40
|
"devDependencies": {
|
|
37
41
|
"typescript": "^5.3.3",
|
|
38
|
-
"zhin.js": "1.0.
|
|
39
|
-
"@zhin.js/adapter-icqq": "1.0.
|
|
42
|
+
"zhin.js": "1.0.52",
|
|
43
|
+
"@zhin.js/adapter-icqq": "1.0.65"
|
|
40
44
|
},
|
|
41
45
|
"peerDependencies": {
|
|
42
|
-
"@zhin.js/adapter-icqq": "1.0.
|
|
43
|
-
"zhin.js": "1.0.
|
|
46
|
+
"@zhin.js/adapter-icqq": "1.0.65",
|
|
47
|
+
"zhin.js": "1.0.52"
|
|
44
48
|
},
|
|
45
49
|
"peerDependenciesMeta": {
|
|
46
50
|
"@zhin.js/adapter-icqq": {
|
package/src/async-jsx.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// async-jsx.ts - 异步 JSX 增强工具
|
|
2
|
+
|
|
3
|
+
import { SendContent } from "zhin.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* 异步组件包装器
|
|
7
|
+
* 类似 Next.js 的异步组件支持
|
|
8
|
+
*/
|
|
9
|
+
export function AsyncComponent<P = any>(
|
|
10
|
+
Component: (props: P) => Promise<SendContent>
|
|
11
|
+
): (props: P) => SendContent {
|
|
12
|
+
// 返回一个同步函数,但内部处理是异步的
|
|
13
|
+
return (props: P) => {
|
|
14
|
+
// 创建一个特殊的标记对象
|
|
15
|
+
const asyncMarker = {
|
|
16
|
+
__async: true,
|
|
17
|
+
component: Component,
|
|
18
|
+
props,
|
|
19
|
+
execute: () => Component(props)
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
// TypeScript 类型断言
|
|
23
|
+
return asyncMarker as any as SendContent;
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* 渲染异步内容
|
|
29
|
+
* 检查是否是异步标记,如果是则执行
|
|
30
|
+
*/
|
|
31
|
+
export async function renderAsync(content: any): Promise<SendContent> {
|
|
32
|
+
if (content && typeof content === 'object' && content.__async) {
|
|
33
|
+
return await content.execute();
|
|
34
|
+
}
|
|
35
|
+
if (content && typeof content.then === 'function') {
|
|
36
|
+
return await content;
|
|
37
|
+
}
|
|
38
|
+
return content;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Suspense 组件工厂
|
|
43
|
+
* 用于包装异步组件并提供 fallback
|
|
44
|
+
*/
|
|
45
|
+
export function createSuspense(fallback: string = '加载中...') {
|
|
46
|
+
return async function Suspense(props: { children: any }): Promise<SendContent> {
|
|
47
|
+
try {
|
|
48
|
+
return await renderAsync(props.children);
|
|
49
|
+
} catch (error) {
|
|
50
|
+
console.error('Suspense error:', error);
|
|
51
|
+
return fallback;
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// plugins/utils/music/src/config.ts
|
|
2
|
+
import type { MusicSourceConfig,MusicSource } from './types.js'
|
|
3
|
+
|
|
4
|
+
/** 音乐源配置映射 */
|
|
5
|
+
export const sourceConfigMap: Record<MusicSource, MusicSourceConfig> = {
|
|
6
|
+
qq: {
|
|
7
|
+
appid: 100497308,
|
|
8
|
+
package: 'com.tencent.qqmusic',
|
|
9
|
+
icon: 'https://p.qpic.cn/qqconnect/0/app_100497308_1626060999/100?max-age=2592000&t=0',
|
|
10
|
+
sign: 'cbd27cd7c861227d013a25b2d10f0799',
|
|
11
|
+
version: '13.11.0.8',
|
|
12
|
+
},
|
|
13
|
+
netease: {
|
|
14
|
+
appid: 100495085,
|
|
15
|
+
package: 'com.netease.cloudmusic',
|
|
16
|
+
icon: 'https://i.gtimg.cn/open/app_icon/00/49/50/85/100495085_100_m.png',
|
|
17
|
+
sign: 'da6b069da1e2982db3e386233f68d76d',
|
|
18
|
+
version: '9.1.92',
|
|
19
|
+
},
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** 格式化时长 */
|
|
23
|
+
export function formatDuration(seconds?: number): string {
|
|
24
|
+
if (!seconds) return '未知'
|
|
25
|
+
|
|
26
|
+
const minutes = Math.floor(seconds / 60)
|
|
27
|
+
const secs = seconds % 60
|
|
28
|
+
return `${minutes}:${secs.toString().padStart(2, '0')}`
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** 格式化音乐信息 */
|
|
32
|
+
export function formatMusicInfo(music: {
|
|
33
|
+
title: string
|
|
34
|
+
artist?: string
|
|
35
|
+
album?: string
|
|
36
|
+
duration?: number
|
|
37
|
+
source: string
|
|
38
|
+
}): string {
|
|
39
|
+
const parts = [music.title]
|
|
40
|
+
|
|
41
|
+
if (music.artist) {
|
|
42
|
+
parts.push(music.artist)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (music.album) {
|
|
46
|
+
parts.push(music.album)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const info = parts.join(' - ')
|
|
50
|
+
const duration = formatDuration(music.duration)
|
|
51
|
+
const source = music.source.toUpperCase()
|
|
52
|
+
|
|
53
|
+
return `${info} [${duration}] [${source}]`
|
|
54
|
+
}
|
package/src/index.tsx
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { usePlugin, defineComponent, Prompt, ZhinTool, type Message } from "zhin.js";
|
|
2
|
+
import type {} from '@zhin.js/adapter-icqq'
|
|
3
|
+
import { musicServices } from "./sources/index.js";
|
|
4
|
+
import { sourceConfigMap } from "./config.js";
|
|
5
|
+
import type { MusicSource } from "./types.js";
|
|
6
|
+
|
|
7
|
+
const plugin = usePlugin();
|
|
8
|
+
const { logger, useContext, addComponent } = plugin;
|
|
9
|
+
|
|
10
|
+
// 异步组件:分享音乐
|
|
11
|
+
const ShareMusic = defineComponent(async function ShareMusic({ platform, musicId }: { platform: MusicSource, musicId: string }) {
|
|
12
|
+
const service = musicServices[platform];
|
|
13
|
+
if (!service) return 'unsupported music source';
|
|
14
|
+
const { id, source, ...detail } = await service.getDetail(musicId);
|
|
15
|
+
return <share {...detail} config={sourceConfigMap[platform]} />
|
|
16
|
+
}, 'ShareMusic')
|
|
17
|
+
addComponent(ShareMusic)
|
|
18
|
+
|
|
19
|
+
// Suspense 组件 - 用于包装异步组件
|
|
20
|
+
const Suspense = defineComponent(async function Suspense(
|
|
21
|
+
props: { fallback?: string; children?: any },
|
|
22
|
+
context
|
|
23
|
+
) {
|
|
24
|
+
try {
|
|
25
|
+
// 如果 children 是一个 Promise(异步组件),等待它
|
|
26
|
+
if (props.children && typeof props.children === 'object' && 'then' in props.children) {
|
|
27
|
+
return await props.children;
|
|
28
|
+
}
|
|
29
|
+
// 否则直接返回
|
|
30
|
+
return props.children || '';
|
|
31
|
+
} catch (error) {
|
|
32
|
+
logger.error('Suspense error:', error);
|
|
33
|
+
return props.fallback || '加载失败';
|
|
34
|
+
}
|
|
35
|
+
}, 'Suspense');
|
|
36
|
+
|
|
37
|
+
addComponent(Suspense);
|
|
38
|
+
|
|
39
|
+
// ============================================================================
|
|
40
|
+
// 点歌工具 (使用 ZhinTool)
|
|
41
|
+
// ============================================================================
|
|
42
|
+
|
|
43
|
+
const searchMusicTool = new ZhinTool('music_search')
|
|
44
|
+
.desc('搜索音乐并返回结果列表')
|
|
45
|
+
.tag('music', 'entertainment')
|
|
46
|
+
.param('keyword', { type: 'string', description: '搜索关键词' }, true)
|
|
47
|
+
.param('source', {
|
|
48
|
+
type: 'string',
|
|
49
|
+
description: '音乐源: qq, netease(默认两者都搜索)',
|
|
50
|
+
enum: ['qq', 'netease']
|
|
51
|
+
})
|
|
52
|
+
.param('limit', { type: 'number', description: '返回结果数量(默认 5)' })
|
|
53
|
+
.execute(async ({ keyword, source, limit = 5 }) => {
|
|
54
|
+
const keywordStr = keyword as string;
|
|
55
|
+
const limitNum = limit as number;
|
|
56
|
+
|
|
57
|
+
// 确定搜索源
|
|
58
|
+
const searchSources: MusicSource[] = source
|
|
59
|
+
? [source as MusicSource]
|
|
60
|
+
: ['qq', 'netease'];
|
|
61
|
+
|
|
62
|
+
logger.info(`AI 搜索音乐: ${keywordStr}, 来源: ${searchSources.join(', ')}`);
|
|
63
|
+
|
|
64
|
+
// 并行搜索
|
|
65
|
+
const searchPromises = searchSources.map(s =>
|
|
66
|
+
musicServices[s].search(keywordStr, limitNum)
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
const searchResults = await Promise.all(searchPromises);
|
|
70
|
+
const allMusic = searchResults.flat().filter(Boolean);
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
success: true,
|
|
74
|
+
keyword: keywordStr,
|
|
75
|
+
results: allMusic.map(m => ({
|
|
76
|
+
id: m.id,
|
|
77
|
+
title: m.title,
|
|
78
|
+
source: m.source,
|
|
79
|
+
url: m.url,
|
|
80
|
+
})),
|
|
81
|
+
total: allMusic.length,
|
|
82
|
+
};
|
|
83
|
+
})
|
|
84
|
+
.platform('icqq') // 限制为 icqq 平台
|
|
85
|
+
.action(async (message: Message, result: any) => {
|
|
86
|
+
const keyword = result.params.keyword;
|
|
87
|
+
const sourcesParam = result.params.sources || [];
|
|
88
|
+
|
|
89
|
+
// 解析音乐源
|
|
90
|
+
const sources: MusicSource[] = [];
|
|
91
|
+
for (const source of sourcesParam) {
|
|
92
|
+
const normalized = source.toLowerCase();
|
|
93
|
+
if (["qq", "netease"].includes(normalized)) {
|
|
94
|
+
sources.push(normalized as MusicSource);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// 如果没有指定音乐源,默认搜索 QQ 和网易云
|
|
99
|
+
const searchSources: MusicSource[] =
|
|
100
|
+
sources.length > 0 ? sources : ["qq", "netease"];
|
|
101
|
+
|
|
102
|
+
logger.info(`搜索音乐: ${keyword}, 来源: ${searchSources.join(", ")}`);
|
|
103
|
+
|
|
104
|
+
// 并行搜索多个音乐源
|
|
105
|
+
const searchPromises = searchSources.map((source) =>
|
|
106
|
+
musicServices[source].search(keyword, 5)
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
const searchResults = await Promise.all(searchPromises);
|
|
110
|
+
const allMusic = searchResults.flat().filter(Boolean);
|
|
111
|
+
|
|
112
|
+
if (allMusic.length === 0) {
|
|
113
|
+
await message.$reply("没有找到结果");
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// 使用 Prompt 让用户选择
|
|
118
|
+
const prompt = new Prompt(plugin, message);
|
|
119
|
+
const musicUrl = await prompt.pick("请选择搜索结果", {
|
|
120
|
+
type: "text",
|
|
121
|
+
options: allMusic.map((music) => ({
|
|
122
|
+
label: `${music.title} [${music.source.toUpperCase()}]`,
|
|
123
|
+
value: music.url,
|
|
124
|
+
})),
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
if (!musicUrl) return;
|
|
128
|
+
|
|
129
|
+
const music = allMusic.find((m) => m.url === musicUrl)!;
|
|
130
|
+
|
|
131
|
+
// 现在支持直接使用 JSX 语法,异步组件会自动 await
|
|
132
|
+
return <ShareMusic platform={music.source} musicId={music.id} />
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
// 分享音乐工具(直接分享指定音乐)
|
|
136
|
+
const shareMusicTool = new ZhinTool('music_share')
|
|
137
|
+
.desc('分享指定的音乐')
|
|
138
|
+
.tag('music', 'entertainment')
|
|
139
|
+
.param('id', { type: 'string', description: '音乐 ID' }, true)
|
|
140
|
+
.param('source', {
|
|
141
|
+
type: 'string',
|
|
142
|
+
description: '音乐源: qq 或 netease',
|
|
143
|
+
enum: ['qq', 'netease']
|
|
144
|
+
}, true)
|
|
145
|
+
.platform('icqq')
|
|
146
|
+
.execute(async ({ id, source }) => {
|
|
147
|
+
const service = musicServices[source as MusicSource];
|
|
148
|
+
if (!service) {
|
|
149
|
+
return { success: false, error: `不支持的音乐源: ${source}` };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
try {
|
|
153
|
+
const detail = await service.getDetail(id as string);
|
|
154
|
+
return {
|
|
155
|
+
success: true,
|
|
156
|
+
music: {
|
|
157
|
+
id: detail.id,
|
|
158
|
+
title: detail.title,
|
|
159
|
+
source: detail.source,
|
|
160
|
+
url: detail.url,
|
|
161
|
+
}
|
|
162
|
+
};
|
|
163
|
+
} catch (error) {
|
|
164
|
+
return { success: false, error: error instanceof Error ? error.message : String(error) };
|
|
165
|
+
}
|
|
166
|
+
})
|
|
167
|
+
.action(async (message: Message, result: any) => {
|
|
168
|
+
const { id, source } = result.params;
|
|
169
|
+
|
|
170
|
+
if (!musicServices[source as MusicSource]) {
|
|
171
|
+
return `❌ 不支持的音乐源: ${source}`;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return <ShareMusic platform={source as MusicSource} musicId={id} />;
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
// 注册工具
|
|
178
|
+
useContext('tool', (toolService) => {
|
|
179
|
+
if (!toolService) return;
|
|
180
|
+
|
|
181
|
+
const disposers = [
|
|
182
|
+
toolService.addTool(searchMusicTool, 'music'),
|
|
183
|
+
toolService.addTool(shareMusicTool, 'music'),
|
|
184
|
+
];
|
|
185
|
+
|
|
186
|
+
logger.debug('音乐工具已注册');
|
|
187
|
+
|
|
188
|
+
return () => disposers.forEach(d => d());
|
|
189
|
+
});
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// plugins/utils/music/src/sources/index.ts
|
|
2
|
+
import { QQMusicService } from './qq.js'
|
|
3
|
+
import { NeteaseMusicService } from './netease.js'
|
|
4
|
+
import type { MusicSource, MusicSearchService } from '../types.js'
|
|
5
|
+
|
|
6
|
+
/** 音乐源服务映射 */
|
|
7
|
+
export const musicServices: Record<MusicSource, MusicSearchService> = {
|
|
8
|
+
qq: new QQMusicService(),
|
|
9
|
+
netease: new NeteaseMusicService(),
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export * from './qq.js'
|
|
13
|
+
export * from './netease.js'
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// plugins/utils/music/src/sources/netease.ts
|
|
2
|
+
import type { MusicSearchService, MusicDetail,MusicInfo, Music163 } from '../types.js'
|
|
3
|
+
|
|
4
|
+
/** 网易云音乐搜索服务 */
|
|
5
|
+
export class NeteaseMusicService implements MusicSearchService {
|
|
6
|
+
async search(keyword: string, limit = 10): Promise<MusicInfo[]> {
|
|
7
|
+
try {
|
|
8
|
+
const searchUrl = `http://music.163.com/api/search/get/web?csrf_token=hlpretag=&hlposttag=&s=${encodeURIComponent(keyword)}&type=1&offset=0&total=true&limit=${limit}`
|
|
9
|
+
|
|
10
|
+
const response = await fetch(searchUrl, { method: 'GET' })
|
|
11
|
+
const data = await response.json() as { result: { songs: Music163[] } }
|
|
12
|
+
const songs = data.result?.songs || []
|
|
13
|
+
|
|
14
|
+
return songs.map(music => ({
|
|
15
|
+
id: music.id,
|
|
16
|
+
source: 'netease' as const,
|
|
17
|
+
title: music.name,
|
|
18
|
+
artist: music.artists?.map(a => a.name).join('/'),
|
|
19
|
+
album: music.album.name,
|
|
20
|
+
url: `https://music.163.com/#/song?id=${music.id}`,
|
|
21
|
+
image: music.album.picUrl || music.album.img1v1Url,
|
|
22
|
+
duration: music.duration ? Math.floor(music.duration / 1000) : undefined,
|
|
23
|
+
}))
|
|
24
|
+
} catch (error) {
|
|
25
|
+
console.error('Netease Music search failed:', error)
|
|
26
|
+
return []
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async getCover(id: string): Promise<string | null> {
|
|
31
|
+
try {
|
|
32
|
+
const url = new URL(`https://music.163.com/api/song/detail/?ids=[${id}]`)
|
|
33
|
+
|
|
34
|
+
const response = await fetch(url, { method: 'GET' })
|
|
35
|
+
const data = await response.json() as { songs: Music163[] }
|
|
36
|
+
|
|
37
|
+
const song = data.songs?.[0]
|
|
38
|
+
if (!song) return null
|
|
39
|
+
|
|
40
|
+
return song.album.picUrl || song.album.img1v1Url || null
|
|
41
|
+
} catch (error) {
|
|
42
|
+
console.error('Netease Music get cover failed:', error)
|
|
43
|
+
return null
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async getDetail(id: string): Promise<MusicDetail> {
|
|
48
|
+
const url = new URL(`https://music.163.com/api/song/detail/?ids=[${id}]`)
|
|
49
|
+
|
|
50
|
+
const response = await fetch(url, { method: 'GET' })
|
|
51
|
+
const data = await response.json() as { songs: Music163[] }
|
|
52
|
+
|
|
53
|
+
const song = data.songs?.[0]
|
|
54
|
+
if (!song) throw new Error('Music not found')
|
|
55
|
+
|
|
56
|
+
return {
|
|
57
|
+
id: song.id,
|
|
58
|
+
source: 'netease',
|
|
59
|
+
title: song.name,
|
|
60
|
+
url: `https://music.163.com/#/song?id=${song.id}`,
|
|
61
|
+
image: song.album.picUrl || song.album.img1v1Url,
|
|
62
|
+
duration: song.duration ? Math.floor(song.duration / 1000) : undefined,
|
|
63
|
+
audio: await this.getAudioUrl(id),
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* 获取音频直链
|
|
69
|
+
* @param id 音乐 ID
|
|
70
|
+
* @param metingAPI Meting API 地址(可选)
|
|
71
|
+
* @returns 音频直链 URL
|
|
72
|
+
*/
|
|
73
|
+
async getAudioUrl(id: string, metingAPI?: string): Promise<string> {
|
|
74
|
+
// 默认使用 Meting API
|
|
75
|
+
const apiUrl = metingAPI || 'https://api.injahow.cn/meting/'
|
|
76
|
+
const url = `${apiUrl}?type=url&id=${id}`
|
|
77
|
+
|
|
78
|
+
const response = await fetch(url, { method: 'GET' })
|
|
79
|
+
const data = await response.json() as { url?: string, data?: { url?: string } }
|
|
80
|
+
if(!data.url && !data.data?.url) throw new Error('Audio URL not found')
|
|
81
|
+
// 不同的 Meting API 返回格式可能不同
|
|
82
|
+
return data.url || data.data?.url!
|
|
83
|
+
}
|
|
84
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
// plugins/utils/music/src/sources/qq.ts
|
|
2
|
+
import type { MusicSearchService, MusicDetail,MusicInfo, MusicQQ } from '../types.js'
|
|
3
|
+
|
|
4
|
+
/** QQ 音乐搜索服务 */
|
|
5
|
+
export class QQMusicService implements MusicSearchService {
|
|
6
|
+
async search(keyword: string, limit = 10): Promise<MusicInfo[]> {
|
|
7
|
+
try {
|
|
8
|
+
const url = new URL('https://c.y.qq.com/splcloud/fcgi-bin/smartbox_new.fcg')
|
|
9
|
+
url.searchParams.set('key', keyword)
|
|
10
|
+
url.searchParams.set('format', 'json')
|
|
11
|
+
|
|
12
|
+
const response = await fetch(url, { method: 'GET' })
|
|
13
|
+
const data = await response.json() as { data: { song: { itemlist: MusicQQ[] } } }
|
|
14
|
+
|
|
15
|
+
const items = data.data?.song?.itemlist || []
|
|
16
|
+
return items.slice(0, limit).map(music => ({
|
|
17
|
+
id: music.id,
|
|
18
|
+
source: 'qq' as const,
|
|
19
|
+
title: music.name,
|
|
20
|
+
url: `https://y.qq.com/n/yqq/song/${music.mid}.html`
|
|
21
|
+
}))
|
|
22
|
+
} catch (error) {
|
|
23
|
+
console.error('QQ Music search failed:', error)
|
|
24
|
+
return []
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async getCover(id: string): Promise<string | null> {
|
|
29
|
+
try {
|
|
30
|
+
const url = new URL('https://u.y.qq.com/cgi-bin/musicu.fcg')
|
|
31
|
+
url.searchParams.set('format', 'json')
|
|
32
|
+
url.searchParams.set('inCharset', 'utf8')
|
|
33
|
+
url.searchParams.set('outCharset', 'utf-8')
|
|
34
|
+
url.searchParams.set('notice', '0')
|
|
35
|
+
url.searchParams.set('platform', 'yqq.json')
|
|
36
|
+
url.searchParams.set('needNewCode', '0')
|
|
37
|
+
url.searchParams.set('data', JSON.stringify({
|
|
38
|
+
comm: { ct: 24, cv: 0 },
|
|
39
|
+
songinfo: {
|
|
40
|
+
method: 'get_song_detail_yqq',
|
|
41
|
+
param: { song_type: 0, song_mid: '', song_id: parseInt(id) },
|
|
42
|
+
module: 'music.pf_song_detail_svr'
|
|
43
|
+
}
|
|
44
|
+
}))
|
|
45
|
+
|
|
46
|
+
const response = await fetch(url, { method: 'GET' })
|
|
47
|
+
const result = await response.json()
|
|
48
|
+
|
|
49
|
+
const albumMid = result?.songinfo?.data?.track_info?.album?.mid
|
|
50
|
+
if (!albumMid) return null
|
|
51
|
+
|
|
52
|
+
return `https://y.gtimg.cn/music/photo_new/T002R300x300M000${albumMid}.jpg`
|
|
53
|
+
} catch (error) {
|
|
54
|
+
console.error('QQ Music get cover failed:', error)
|
|
55
|
+
return null
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async getDetail(id: string): Promise<MusicDetail> {
|
|
60
|
+
const url = new URL('https://u.y.qq.com/cgi-bin/musicu.fcg')
|
|
61
|
+
url.searchParams.set('format', 'json')
|
|
62
|
+
url.searchParams.set('data', JSON.stringify({
|
|
63
|
+
comm: { ct: 24, cv: 0 },
|
|
64
|
+
songinfo: {
|
|
65
|
+
method: 'get_song_detail_yqq',
|
|
66
|
+
param: { song_type: 0, song_mid: '', song_id: parseInt(id) },
|
|
67
|
+
module: 'music.pf_song_detail_svr'
|
|
68
|
+
}
|
|
69
|
+
}))
|
|
70
|
+
|
|
71
|
+
const response = await fetch(url, { method: 'GET' })
|
|
72
|
+
const result = await response.json()
|
|
73
|
+
|
|
74
|
+
const trackInfo = result?.songinfo?.data?.track_info
|
|
75
|
+
if (!trackInfo) throw new Error('Music not found')
|
|
76
|
+
|
|
77
|
+
const albumMid = trackInfo.album?.mid
|
|
78
|
+
const image = albumMid
|
|
79
|
+
? `https://y.gtimg.cn/music/photo_new/T002R300x300M000${albumMid}.jpg`
|
|
80
|
+
: undefined
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
id,
|
|
84
|
+
source: 'qq',
|
|
85
|
+
title: trackInfo.name,
|
|
86
|
+
url: `https://y.qq.com/n/yqq/song/${trackInfo.mid}.html`,
|
|
87
|
+
audio: await this.getAudioUrl(id),
|
|
88
|
+
image: image || '',
|
|
89
|
+
duration: trackInfo.interval,
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* 获取音频直链(需要第三方 API)
|
|
95
|
+
* @param id 音乐 ID
|
|
96
|
+
* @param metingAPI Meting API 地址(可选)
|
|
97
|
+
* @returns 音频直链 URL
|
|
98
|
+
*/
|
|
99
|
+
async getAudioUrl(id: string, metingAPI?: string): Promise<string> {
|
|
100
|
+
// QQ 音乐需要使用第三方 API
|
|
101
|
+
const apiUrl = metingAPI || 'https://api.injahow.cn/meting/'
|
|
102
|
+
const url = `${apiUrl}?type=url&id=${id}&source=qq`
|
|
103
|
+
|
|
104
|
+
const response = await fetch(url, { method: 'GET' })
|
|
105
|
+
const data = await response.json() as { url?: string, data?: { url?: string } }
|
|
106
|
+
if(!data.url && !data.data?.url) {
|
|
107
|
+
throw new Error('Audio URL not found')
|
|
108
|
+
}
|
|
109
|
+
return data.url || data.data?.url!
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* 获取歌词
|
|
114
|
+
* @param id 音乐 ID
|
|
115
|
+
* @returns 歌词文本
|
|
116
|
+
*/
|
|
117
|
+
async getLyric(id: string): Promise<string | null> {
|
|
118
|
+
try {
|
|
119
|
+
const url = new URL('https://c.y.qq.com/lyric/fcgi-bin/fcg_query_lyric_new.fcg')
|
|
120
|
+
url.searchParams.set('songmid', id)
|
|
121
|
+
url.searchParams.set('g_tk', '5381')
|
|
122
|
+
url.searchParams.set('format', 'json')
|
|
123
|
+
url.searchParams.set('inCharset', 'utf8')
|
|
124
|
+
url.searchParams.set('outCharset', 'utf-8')
|
|
125
|
+
url.searchParams.set('nobase64', '1')
|
|
126
|
+
|
|
127
|
+
const response = await fetch(url, {
|
|
128
|
+
method: 'GET',
|
|
129
|
+
headers: {
|
|
130
|
+
'Referer': 'https://y.qq.com'
|
|
131
|
+
}
|
|
132
|
+
})
|
|
133
|
+
const data = await response.json() as { lyric?: string }
|
|
134
|
+
|
|
135
|
+
if (!data.lyric) return null
|
|
136
|
+
|
|
137
|
+
// QQ 音乐返回的歌词需要 base64 解码
|
|
138
|
+
try {
|
|
139
|
+
return Buffer.from(data.lyric, 'base64').toString('utf-8')
|
|
140
|
+
} catch {
|
|
141
|
+
return data.lyric
|
|
142
|
+
}
|
|
143
|
+
} catch (error) {
|
|
144
|
+
console.error('QQ Music get lyric failed:', error)
|
|
145
|
+
return null
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// plugins/utils/music/src/types.ts
|
|
2
|
+
|
|
3
|
+
/** 音乐源类型 */
|
|
4
|
+
export type MusicSource = "qq" | "netease";
|
|
5
|
+
|
|
6
|
+
/** 音乐分享内容 */
|
|
7
|
+
export interface MusicInfo {
|
|
8
|
+
id: string;
|
|
9
|
+
source: MusicSource;
|
|
10
|
+
/** 跳转链接 */
|
|
11
|
+
url: string;
|
|
12
|
+
/** 音乐标题 */
|
|
13
|
+
title: string;
|
|
14
|
+
}
|
|
15
|
+
export interface MusicDetail extends MusicInfo {
|
|
16
|
+
image: string;
|
|
17
|
+
audio:string;
|
|
18
|
+
duration?: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** 音乐源配置 */
|
|
22
|
+
export interface MusicSourceConfig {
|
|
23
|
+
appid: number;
|
|
24
|
+
package: string;
|
|
25
|
+
icon: string;
|
|
26
|
+
sign: string;
|
|
27
|
+
version: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** QQ 音乐 API 响应 */
|
|
31
|
+
export interface MusicQQ {
|
|
32
|
+
id: string;
|
|
33
|
+
mid: string;
|
|
34
|
+
name: string;
|
|
35
|
+
docid: string;
|
|
36
|
+
singer: string;
|
|
37
|
+
album?: {
|
|
38
|
+
mid: string;
|
|
39
|
+
name: string;
|
|
40
|
+
};
|
|
41
|
+
interval?: number;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** 网易云音乐 API 响应 */
|
|
45
|
+
export interface Music163 {
|
|
46
|
+
id: string;
|
|
47
|
+
name: string;
|
|
48
|
+
duration?: number;
|
|
49
|
+
artists?: Array<{
|
|
50
|
+
id: string;
|
|
51
|
+
name: string;
|
|
52
|
+
}>;
|
|
53
|
+
album: {
|
|
54
|
+
id: string;
|
|
55
|
+
name: string;
|
|
56
|
+
picUrl: string | null;
|
|
57
|
+
img1v1Url: string;
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** 音乐搜索服务接口 */
|
|
62
|
+
export interface MusicSearchService {
|
|
63
|
+
/** 搜索音乐 */
|
|
64
|
+
search(keyword: string, limit?: number): Promise<MusicInfo[]>;
|
|
65
|
+
/** 获取音乐详情 */
|
|
66
|
+
getDetail(id: string): Promise<MusicDetail>;
|
|
67
|
+
}
|