@zhin.js/plugin-music 1.0.26 → 1.1.2
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 +28 -27
- package/commands/cookie/delete/[source]/index.js +23 -0
- package/commands/cookie/delete/[source]/index.ts +27 -0
- package/commands/cookie/get/[source]/index.js +29 -0
- package/commands/cookie/get/[source]/index.ts +33 -0
- package/commands/cookie/set/[source]/index.js +23 -0
- package/commands/cookie/set/[source]/index.ts +27 -0
- package/commands/cookie/status/index.js +25 -0
- package/commands/cookie/status/index.ts +25 -0
- package/commands/login/[platform]/index.js +66 -0
- package/commands/login/[platform]/index.ts +74 -0
- package/commands//347/202/271/346/255/214/[keyword]/index.js +63 -0
- package/commands//347/202/271/346/255/214/[keyword]/index.ts +75 -0
- package/components/share-music/index.js +24 -0
- package/components/share-music/index.ts +29 -0
- package/lib/config.d.ts +3 -3
- package/lib/config.d.ts.map +1 -1
- package/lib/config.js +36 -7
- package/lib/config.js.map +1 -1
- package/lib/credential-store.d.ts +37 -0
- package/lib/credential-store.d.ts.map +1 -0
- package/lib/credential-store.js +85 -0
- package/lib/credential-store.js.map +1 -0
- package/lib/index.d.ts +8 -1
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +7 -112
- package/lib/index.js.map +1 -1
- package/lib/login/index.d.ts +27 -0
- package/lib/login/index.d.ts.map +1 -0
- package/lib/login/index.js +124 -0
- package/lib/login/index.js.map +1 -0
- package/lib/login/netease.d.ts +6 -0
- package/lib/login/netease.d.ts.map +1 -0
- package/lib/login/netease.js +58 -0
- package/lib/login/netease.js.map +1 -0
- package/lib/login/qq.d.ts +7 -0
- package/lib/login/qq.d.ts.map +1 -0
- package/lib/login/qq.js +139 -0
- package/lib/login/qq.js.map +1 -0
- package/lib/login/types.d.ts +19 -0
- package/lib/login/types.d.ts.map +1 -0
- package/lib/login/types.js +11 -0
- package/lib/login/types.js.map +1 -0
- package/lib/music-lib.d.ts +32 -0
- package/lib/music-lib.d.ts.map +1 -0
- package/lib/music-lib.js +58 -0
- package/lib/music-lib.js.map +1 -0
- package/lib/runtime.d.ts +12 -0
- package/lib/runtime.d.ts.map +1 -0
- package/lib/runtime.js +3 -0
- package/lib/runtime.js.map +1 -0
- package/lib/session.d.ts +35 -0
- package/lib/session.d.ts.map +1 -0
- package/lib/session.js +51 -0
- package/lib/session.js.map +1 -0
- package/lib/sources/index.d.ts +4 -2
- package/lib/sources/index.d.ts.map +1 -1
- package/lib/sources/index.js +12 -6
- package/lib/sources/index.js.map +1 -1
- package/lib/sources/kugou.d.ts +6 -0
- package/lib/sources/kugou.d.ts.map +1 -0
- package/lib/sources/kugou.js +69 -0
- package/lib/sources/kugou.js.map +1 -0
- package/lib/sources/kuwo.d.ts +7 -0
- package/lib/sources/kuwo.d.ts.map +1 -0
- package/lib/sources/kuwo.js +69 -0
- package/lib/sources/kuwo.js.map +1 -0
- package/lib/sources/netease.d.ts +4 -1
- package/lib/sources/netease.d.ts.map +1 -1
- package/lib/sources/netease.js +25 -6
- package/lib/sources/netease.js.map +1 -1
- package/lib/sources/qq.d.ts +4 -14
- package/lib/sources/qq.d.ts.map +1 -1
- package/lib/sources/qq.js +117 -82
- package/lib/sources/qq.js.map +1 -1
- package/lib/types.d.ts +4 -24
- package/lib/types.d.ts.map +1 -1
- package/lib/types.js +0 -1
- package/lib/types.js.map +1 -1
- package/middlewares/music-selection/index.js +48 -0
- package/middlewares/music-selection/index.ts +62 -0
- package/package.json +66 -12
- package/plugin.js +45 -0
- package/schema.json +6 -0
- package/skills/music/SKILL.md +2 -2
- package/skills/music/tools/music-search/index.js +22 -0
- package/skills/music/tools/music-search/index.ts +33 -0
- package/skills/music/tools/music-share/index.js +21 -0
- package/skills/music/tools/music-share/index.ts +26 -0
- package/src/config.ts +54 -30
- package/src/credential-store.ts +125 -0
- package/src/index.ts +37 -0
- package/src/login/index.ts +154 -0
- package/src/login/netease.ts +69 -0
- package/src/login/qq.ts +154 -0
- package/src/login/types.ts +27 -0
- package/src/music-lib.ts +71 -0
- package/src/runtime.ts +17 -0
- package/src/session.ts +77 -0
- package/src/sources/index.ts +17 -10
- package/src/sources/kugou.ts +92 -0
- package/src/sources/kuwo.ts +94 -0
- package/src/sources/netease.ts +35 -11
- package/src/sources/qq.ts +164 -126
- package/src/types.ts +6 -28
- package/lib/async-jsx.d.ts +0 -19
- package/lib/async-jsx.d.ts.map +0 -1
- package/lib/async-jsx.js +0 -48
- package/lib/async-jsx.js.map +0 -1
- package/src/async-jsx.ts +0 -54
- package/src/index.tsx +0 -130
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import type { QrLoginProvider, QrLoginSource, QrPollResult } from './types.js';
|
|
2
|
+
import { QQLoginProvider } from './qq.js';
|
|
3
|
+
import { NeteaseLoginProvider } from './netease.js';
|
|
4
|
+
import type { CredentialStore } from '../credential-store.js';
|
|
5
|
+
|
|
6
|
+
export type { QrLoginSource, QrPollResult, QrLoginProvider } from './types.js';
|
|
7
|
+
|
|
8
|
+
export interface QrLoginSession {
|
|
9
|
+
source: QrLoginSource;
|
|
10
|
+
pollData: Record<string, string>;
|
|
11
|
+
timestamp: number;
|
|
12
|
+
aborted: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const LOGIN_TIMEOUT_MS = 2 * 60 * 1000;
|
|
16
|
+
const POLL_INTERVAL_MS = 3_000;
|
|
17
|
+
const MAX_POLLS = Math.ceil(LOGIN_TIMEOUT_MS / POLL_INTERVAL_MS);
|
|
18
|
+
|
|
19
|
+
export function loginSessionKey(
|
|
20
|
+
endpointId: string,
|
|
21
|
+
conversationId: string,
|
|
22
|
+
senderId: string,
|
|
23
|
+
): string {
|
|
24
|
+
return `login:${endpointId}:${conversationId}:${senderId}`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Generation-owned QR login coordinator. */
|
|
28
|
+
export class QrLoginRuntime {
|
|
29
|
+
readonly #active = new Map<string, QrLoginSession>();
|
|
30
|
+
readonly #providers: Readonly<Record<QrLoginSource, QrLoginProvider>>;
|
|
31
|
+
readonly #credentials: CredentialStore;
|
|
32
|
+
readonly #now: () => number;
|
|
33
|
+
readonly #sleep: (ms: number) => Promise<void>;
|
|
34
|
+
|
|
35
|
+
constructor(
|
|
36
|
+
credentials: CredentialStore,
|
|
37
|
+
providers?: Readonly<Record<QrLoginSource, QrLoginProvider>>,
|
|
38
|
+
now: () => number = Date.now,
|
|
39
|
+
sleep: (ms: number) => Promise<void> = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
40
|
+
) {
|
|
41
|
+
this.#credentials = credentials;
|
|
42
|
+
this.#now = now;
|
|
43
|
+
this.#sleep = sleep;
|
|
44
|
+
this.#providers = providers ?? {
|
|
45
|
+
qq: new QQLoginProvider(),
|
|
46
|
+
netease: new NeteaseLoginProvider(),
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
get(key: string): QrLoginSession | undefined {
|
|
51
|
+
const session = this.#active.get(key);
|
|
52
|
+
if (!session) return undefined;
|
|
53
|
+
if (this.#now() - session.timestamp > LOGIN_TIMEOUT_MS) {
|
|
54
|
+
session.aborted = true;
|
|
55
|
+
this.#active.delete(key);
|
|
56
|
+
return undefined;
|
|
57
|
+
}
|
|
58
|
+
return session;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
cancel(key: string): boolean {
|
|
62
|
+
const session = this.#active.get(key);
|
|
63
|
+
if (!session) return false;
|
|
64
|
+
session.aborted = true;
|
|
65
|
+
this.#active.delete(key);
|
|
66
|
+
return true;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async start(
|
|
70
|
+
source: QrLoginSource,
|
|
71
|
+
key: string,
|
|
72
|
+
): Promise<{ imageSegment: { type: 'image'; data: Record<string, unknown> } }> {
|
|
73
|
+
const existing = this.#active.get(key);
|
|
74
|
+
if (existing && !existing.aborted) existing.aborted = true;
|
|
75
|
+
const session: QrLoginSession = {
|
|
76
|
+
source,
|
|
77
|
+
pollData: {},
|
|
78
|
+
timestamp: this.#now(),
|
|
79
|
+
aborted: false,
|
|
80
|
+
};
|
|
81
|
+
this.#active.set(key, session);
|
|
82
|
+
|
|
83
|
+
try {
|
|
84
|
+
const result = await this.#providers[source].createQr();
|
|
85
|
+
if (session.aborted || this.#active.get(key) !== session) {
|
|
86
|
+
throw new Error('登录已取消');
|
|
87
|
+
}
|
|
88
|
+
session.pollData = result.pollData;
|
|
89
|
+
return { imageSegment: result.imageSegment };
|
|
90
|
+
} catch (error) {
|
|
91
|
+
if (this.#active.get(key) === session) this.#active.delete(key);
|
|
92
|
+
throw error;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async poll(
|
|
97
|
+
key: string,
|
|
98
|
+
onStatus: (result: QrPollResult) => Promise<void>,
|
|
99
|
+
): Promise<QrPollResult> {
|
|
100
|
+
const session = this.#active.get(key);
|
|
101
|
+
if (!session) return { status: 'error', message: '没有进行中的登录会话' };
|
|
102
|
+
|
|
103
|
+
const provider = this.#providers[session.source];
|
|
104
|
+
let lastStatus = '';
|
|
105
|
+
try {
|
|
106
|
+
for (let i = 0; i < MAX_POLLS; i++) {
|
|
107
|
+
if (session.aborted) {
|
|
108
|
+
this.#active.delete(key);
|
|
109
|
+
return { status: 'error', message: '登录已取消' };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const result = await provider.pollQr(session.pollData);
|
|
113
|
+
if (result.status !== lastStatus) {
|
|
114
|
+
lastStatus = result.status;
|
|
115
|
+
await onStatus(result);
|
|
116
|
+
}
|
|
117
|
+
if (result.status === 'confirmed') {
|
|
118
|
+
this.#active.delete(key);
|
|
119
|
+
if (result.cookie) await this.#credentials.set(session.source, 'cookie', result.cookie);
|
|
120
|
+
return result;
|
|
121
|
+
}
|
|
122
|
+
if (result.status === 'expired' || result.status === 'error') {
|
|
123
|
+
this.#active.delete(key);
|
|
124
|
+
return result;
|
|
125
|
+
}
|
|
126
|
+
await this.#sleep(POLL_INTERVAL_MS);
|
|
127
|
+
}
|
|
128
|
+
} catch (error) {
|
|
129
|
+
this.#active.delete(key);
|
|
130
|
+
return {
|
|
131
|
+
status: 'error',
|
|
132
|
+
message: `轮询异常:${error instanceof Error ? error.message : String(error)}`,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
this.#active.delete(key);
|
|
137
|
+
return { status: 'expired', message: '登录超时,请重试' };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
pruneExpired(): void {
|
|
141
|
+
const now = this.#now();
|
|
142
|
+
for (const [key, session] of this.#active) {
|
|
143
|
+
if (now - session.timestamp > LOGIN_TIMEOUT_MS) {
|
|
144
|
+
session.aborted = true;
|
|
145
|
+
this.#active.delete(key);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
dispose(): void {
|
|
151
|
+
for (const session of this.#active.values()) session.aborted = true;
|
|
152
|
+
this.#active.clear();
|
|
153
|
+
}
|
|
154
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import type { QrLoginProvider, QrCreateResult, QrPollResult } from './types.js';
|
|
2
|
+
import { extractSetCookies } from './types.js';
|
|
3
|
+
|
|
4
|
+
const QR_IMAGE_API = 'https://api.qrserver.com/v1/create-qr-code/';
|
|
5
|
+
|
|
6
|
+
export class NeteaseLoginProvider implements QrLoginProvider {
|
|
7
|
+
async createQr(): Promise<QrCreateResult> {
|
|
8
|
+
const timestampMs = Date.now();
|
|
9
|
+
const keyResp = await fetch(
|
|
10
|
+
`https://music.163.com/api/login/qrcode/unikey?type=1×tamp=${timestampMs}`,
|
|
11
|
+
{
|
|
12
|
+
method: 'POST',
|
|
13
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
14
|
+
body: `type=1×tamp=${timestampMs}`,
|
|
15
|
+
},
|
|
16
|
+
);
|
|
17
|
+
const keyData = (await keyResp.json()) as { code: number; unikey?: string };
|
|
18
|
+
if (keyData.code !== 200 || !keyData.unikey) {
|
|
19
|
+
throw new Error('获取网易云登录二维码失败');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const loginUrl = `https://music.163.com/login?codekey=${keyData.unikey}`;
|
|
23
|
+
const imageUrl = `${QR_IMAGE_API}?size=300x300&data=${encodeURIComponent(loginUrl)}`;
|
|
24
|
+
|
|
25
|
+
return {
|
|
26
|
+
imageSegment: { type: 'image', data: { url: imageUrl } },
|
|
27
|
+
pollData: { unikey: keyData.unikey },
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async pollQr(pollData: Record<string, string>): Promise<QrPollResult> {
|
|
32
|
+
const { unikey } = pollData;
|
|
33
|
+
try {
|
|
34
|
+
const response = await fetch(
|
|
35
|
+
'https://music.163.com/api/login/qrcode/client/login',
|
|
36
|
+
{
|
|
37
|
+
method: 'POST',
|
|
38
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
39
|
+
body: `key=${unikey}&type=1`,
|
|
40
|
+
},
|
|
41
|
+
);
|
|
42
|
+
const data = (await response.json()) as { code: number; message?: string };
|
|
43
|
+
|
|
44
|
+
switch (data.code) {
|
|
45
|
+
case 801:
|
|
46
|
+
return { status: 'waiting', message: '等待扫码...' };
|
|
47
|
+
case 802:
|
|
48
|
+
return { status: 'scanned', message: '已扫码,请在手机上确认登录' };
|
|
49
|
+
case 800:
|
|
50
|
+
return { status: 'expired', message: '二维码已过期,请重新发起登录' };
|
|
51
|
+
case 803: {
|
|
52
|
+
const cookie = extractSetCookies(response.headers);
|
|
53
|
+
return {
|
|
54
|
+
status: 'confirmed',
|
|
55
|
+
message: '登录成功',
|
|
56
|
+
cookie: cookie || undefined,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
default:
|
|
60
|
+
return { status: 'error', message: data.message ?? '未知状态' };
|
|
61
|
+
}
|
|
62
|
+
} catch (err) {
|
|
63
|
+
return {
|
|
64
|
+
status: 'error',
|
|
65
|
+
message: `轮询失败:${err instanceof Error ? err.message : String(err)}`,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
package/src/login/qq.ts
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import type { QrLoginProvider, QrCreateResult, QrPollResult } from './types.js';
|
|
2
|
+
import { extractSetCookies } from './types.js';
|
|
3
|
+
|
|
4
|
+
function hashPtqrtoken(qrsig: string): number {
|
|
5
|
+
let hash = 0;
|
|
6
|
+
for (let i = 0; i < qrsig.length; i++) {
|
|
7
|
+
hash += (hash << 5) + qrsig.charCodeAt(i);
|
|
8
|
+
}
|
|
9
|
+
return hash & 0x7fffffff;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const APPID = '716027609';
|
|
13
|
+
const DAID = '383';
|
|
14
|
+
const PT_3RD_AID = '100497308';
|
|
15
|
+
const REFERER = 'https://xui.ptlogin2.qq.com/';
|
|
16
|
+
const USER_AGENT =
|
|
17
|
+
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36';
|
|
18
|
+
|
|
19
|
+
function commonHeaders(extra?: Record<string, string>): Record<string, string> {
|
|
20
|
+
return { 'User-Agent': USER_AGENT, Referer: REFERER, ...extra };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export class QQLoginProvider implements QrLoginProvider {
|
|
24
|
+
async createQr(): Promise<QrCreateResult> {
|
|
25
|
+
// Step 1: 访问 xlogin 页面获取 pt_login_sig
|
|
26
|
+
const xloginUrl = new URL('https://xui.ptlogin2.qq.com/cgi-bin/xlogin');
|
|
27
|
+
xloginUrl.searchParams.set('appid', APPID);
|
|
28
|
+
xloginUrl.searchParams.set('daid', DAID);
|
|
29
|
+
xloginUrl.searchParams.set('pt_3rd_aid', PT_3RD_AID);
|
|
30
|
+
xloginUrl.searchParams.set('s_url', 'https://y.qq.com/');
|
|
31
|
+
xloginUrl.searchParams.set('style', '33');
|
|
32
|
+
xloginUrl.searchParams.set('low_login', '0');
|
|
33
|
+
xloginUrl.searchParams.set('qlogin_auto_login', '1');
|
|
34
|
+
|
|
35
|
+
const xloginResp = await fetch(xloginUrl.toString(), {
|
|
36
|
+
headers: commonHeaders(),
|
|
37
|
+
redirect: 'manual',
|
|
38
|
+
});
|
|
39
|
+
const xloginCookies = extractSetCookies(xloginResp.headers);
|
|
40
|
+
const loginSigMatch = xloginCookies.match(/pt_login_sig=([^;]+)/);
|
|
41
|
+
const loginSig = loginSigMatch?.[1] ?? '';
|
|
42
|
+
|
|
43
|
+
// Step 2: 获取二维码图片 + qrsig cookie
|
|
44
|
+
const qrUrl = new URL('https://ssl.ptlogin2.qq.com/ptqrshow');
|
|
45
|
+
qrUrl.searchParams.set('appid', APPID);
|
|
46
|
+
qrUrl.searchParams.set('e', '2');
|
|
47
|
+
qrUrl.searchParams.set('l', 'M');
|
|
48
|
+
qrUrl.searchParams.set('s', '3');
|
|
49
|
+
qrUrl.searchParams.set('d', '72');
|
|
50
|
+
qrUrl.searchParams.set('v', '4');
|
|
51
|
+
qrUrl.searchParams.set('t', String(Math.random()));
|
|
52
|
+
qrUrl.searchParams.set('daid', DAID);
|
|
53
|
+
qrUrl.searchParams.set('pt_3rd_aid', PT_3RD_AID);
|
|
54
|
+
|
|
55
|
+
const qrResp = await fetch(qrUrl.toString(), {
|
|
56
|
+
headers: commonHeaders({
|
|
57
|
+
Cookie: loginSig ? `pt_login_sig=${loginSig}` : '',
|
|
58
|
+
}),
|
|
59
|
+
redirect: 'manual',
|
|
60
|
+
});
|
|
61
|
+
const qrCookies = extractSetCookies(qrResp.headers);
|
|
62
|
+
const qrsigMatch = qrCookies.match(/qrsig=([^;]+)/);
|
|
63
|
+
const qrsig = qrsigMatch?.[1] ?? '';
|
|
64
|
+
|
|
65
|
+
const buffer = Buffer.from(await qrResp.arrayBuffer());
|
|
66
|
+
const imageBase64 = buffer.toString('base64');
|
|
67
|
+
const ptqrtoken = String(hashPtqrtoken(qrsig));
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
imageSegment: {
|
|
71
|
+
type: 'image',
|
|
72
|
+
data: { base64: imageBase64, mime_type: 'image/png' },
|
|
73
|
+
},
|
|
74
|
+
pollData: { ptqrtoken, qrsig, loginSig },
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async pollQr(pollData: Record<string, string>): Promise<QrPollResult> {
|
|
79
|
+
const { ptqrtoken, qrsig, loginSig } = pollData;
|
|
80
|
+
const url = new URL('https://ssl.ptlogin2.qq.com/ptqrlogin');
|
|
81
|
+
url.searchParams.set('u1', 'https://y.qq.com/');
|
|
82
|
+
url.searchParams.set('ptqrtoken', ptqrtoken!);
|
|
83
|
+
url.searchParams.set('ptredirect', '0');
|
|
84
|
+
url.searchParams.set('h', '1');
|
|
85
|
+
url.searchParams.set('t', '1');
|
|
86
|
+
url.searchParams.set('g', '1');
|
|
87
|
+
url.searchParams.set('from_ui', '1');
|
|
88
|
+
url.searchParams.set('ptlang', '2052');
|
|
89
|
+
url.searchParams.set('action', `0-0-${Date.now()}`);
|
|
90
|
+
url.searchParams.set('js_ver', '24112817');
|
|
91
|
+
url.searchParams.set('js_type', '1');
|
|
92
|
+
url.searchParams.set('login_sig', loginSig ?? '');
|
|
93
|
+
url.searchParams.set('pt_uistyle', '40');
|
|
94
|
+
url.searchParams.set('aid', APPID);
|
|
95
|
+
url.searchParams.set('daid', DAID);
|
|
96
|
+
url.searchParams.set('pt_3rd_aid', PT_3RD_AID);
|
|
97
|
+
url.searchParams.set('has_signing', '1');
|
|
98
|
+
|
|
99
|
+
try {
|
|
100
|
+
const cookieParts = [`qrsig=${qrsig}`];
|
|
101
|
+
if (loginSig) cookieParts.push(`pt_login_sig=${loginSig}`);
|
|
102
|
+
|
|
103
|
+
const response = await fetch(url.toString(), {
|
|
104
|
+
headers: commonHeaders({ Cookie: cookieParts.join('; ') }),
|
|
105
|
+
redirect: 'manual',
|
|
106
|
+
});
|
|
107
|
+
const text = await response.text();
|
|
108
|
+
|
|
109
|
+
if (text.includes('二维码未失效')) {
|
|
110
|
+
return { status: 'waiting', message: '等待扫码...' };
|
|
111
|
+
}
|
|
112
|
+
if (text.includes('二维码认证中') || text.includes('已扫码')) {
|
|
113
|
+
return { status: 'scanned', message: '已扫码,请在手机上确认登录' };
|
|
114
|
+
}
|
|
115
|
+
if (text.includes('二维码已失效')) {
|
|
116
|
+
return { status: 'expired', message: '二维码已过期,请重新发起登录' };
|
|
117
|
+
}
|
|
118
|
+
if (text.includes('登录成功') || text.includes('ptsigx')) {
|
|
119
|
+
return this.handleLoginSuccess(text, response.headers);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (!text) {
|
|
123
|
+
return { status: 'error', message: `服务端返回空响应 (HTTP ${response.status})` };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return { status: 'waiting', message: '等待扫码...' };
|
|
127
|
+
} catch (err) {
|
|
128
|
+
return {
|
|
129
|
+
status: 'error',
|
|
130
|
+
message: `轮询失败:${err instanceof Error ? err.message : String(err)}`,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
private async handleLoginSuccess(text: string, headers: Headers): Promise<QrPollResult> {
|
|
136
|
+
const redirectMatch = text.match(/'(https?:\/\/[^']+)'/);
|
|
137
|
+
if (redirectMatch) {
|
|
138
|
+
const redirectResp = await fetch(redirectMatch[1]!, {
|
|
139
|
+
headers: commonHeaders(),
|
|
140
|
+
redirect: 'manual',
|
|
141
|
+
});
|
|
142
|
+
const cookie = extractSetCookies(redirectResp.headers);
|
|
143
|
+
if (cookie) {
|
|
144
|
+
return { status: 'confirmed', message: '登录成功', cookie };
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
const cookie = extractSetCookies(headers);
|
|
148
|
+
return {
|
|
149
|
+
status: cookie ? 'confirmed' : 'error',
|
|
150
|
+
message: cookie ? '登录成功' : '登录成功但获取 cookie 失败',
|
|
151
|
+
cookie: cookie || undefined,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export type QrLoginSource = 'qq' | 'netease';
|
|
2
|
+
|
|
3
|
+
export interface QrPollResult {
|
|
4
|
+
status: 'waiting' | 'scanned' | 'confirmed' | 'expired' | 'error';
|
|
5
|
+
message: string;
|
|
6
|
+
cookie?: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface QrCreateResult {
|
|
10
|
+
imageSegment: { type: 'image'; data: Record<string, unknown> };
|
|
11
|
+
pollData: Record<string, string>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface QrLoginProvider {
|
|
15
|
+
createQr(): Promise<QrCreateResult>;
|
|
16
|
+
pollQr(pollData: Record<string, string>): Promise<QrPollResult>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function extractSetCookies(headers: Headers): string {
|
|
20
|
+
const raw = headers.getSetCookie?.() ?? [];
|
|
21
|
+
const cookies: string[] = [];
|
|
22
|
+
for (const entry of raw) {
|
|
23
|
+
const match = entry.match(/^([^;]+)/);
|
|
24
|
+
if (match) cookies.push(match[1]!);
|
|
25
|
+
}
|
|
26
|
+
return cookies.join('; ');
|
|
27
|
+
}
|
package/src/music-lib.ts
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { sourceConfigMap, SOURCE_DISPLAY_NAME, formatDuration } from './config.js';
|
|
2
|
+
import type { MusicInfo, MusicDetail, MusicSource } from './types.js';
|
|
3
|
+
import type { MusicRuntime } from './runtime.js';
|
|
4
|
+
|
|
5
|
+
export async function searchMusic(
|
|
6
|
+
runtime: MusicRuntime,
|
|
7
|
+
keyword: string,
|
|
8
|
+
source?: MusicSource,
|
|
9
|
+
limit = 5,
|
|
10
|
+
): Promise<{
|
|
11
|
+
success: true;
|
|
12
|
+
keyword: string;
|
|
13
|
+
source: MusicSource;
|
|
14
|
+
results: MusicInfo[];
|
|
15
|
+
total: number;
|
|
16
|
+
}> {
|
|
17
|
+
const s: MusicSource = source ?? 'qq';
|
|
18
|
+
const service = runtime.services[s];
|
|
19
|
+
const results = await service.search(keyword, limit);
|
|
20
|
+
return {
|
|
21
|
+
success: true,
|
|
22
|
+
keyword,
|
|
23
|
+
source: s,
|
|
24
|
+
results,
|
|
25
|
+
total: results.length,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function shareMusicDetail(runtime: MusicRuntime, id: string, source: MusicSource) {
|
|
30
|
+
const service = runtime.services[source];
|
|
31
|
+
if (!service) {
|
|
32
|
+
return { success: false as const, error: `不支持的音乐源: ${source}` };
|
|
33
|
+
}
|
|
34
|
+
try {
|
|
35
|
+
const detail = await service.getDetail(id);
|
|
36
|
+
return { success: true as const, music: detail };
|
|
37
|
+
} catch (error) {
|
|
38
|
+
return {
|
|
39
|
+
success: false as const,
|
|
40
|
+
error: error instanceof Error ? error.message : String(error),
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function formatSearchResults(results: readonly MusicInfo[], source: MusicSource): string {
|
|
46
|
+
const sourceName = SOURCE_DISPLAY_NAME[source] ?? source;
|
|
47
|
+
if (results.length === 0) return `[${sourceName}] 未找到相关歌曲`;
|
|
48
|
+
const lines = results.map((m, i) => {
|
|
49
|
+
const parts = [m.title];
|
|
50
|
+
if (m.artist) parts.push(m.artist);
|
|
51
|
+
const duration = m.duration ? ` [${formatDuration(m.duration)}]` : '';
|
|
52
|
+
return `${i + 1}. ${parts.join(' - ')}${duration}`;
|
|
53
|
+
});
|
|
54
|
+
return `[${sourceName}] 搜索结果:\n${lines.join('\n')}\n\n回复序号播放,回复"取消"取消选择`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function buildMusicShareSegment(detail: MusicDetail) {
|
|
58
|
+
const config = sourceConfigMap[detail.source];
|
|
59
|
+
return {
|
|
60
|
+
type: 'share' as const,
|
|
61
|
+
data: {
|
|
62
|
+
title: detail.title,
|
|
63
|
+
url: detail.url,
|
|
64
|
+
image: detail.image,
|
|
65
|
+
audio: detail.audio,
|
|
66
|
+
duration: detail.duration,
|
|
67
|
+
artist: detail.artist,
|
|
68
|
+
config,
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
}
|
package/src/runtime.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { createToken } from 'zhin.js';
|
|
2
|
+
import type { CredentialStore } from './credential-store.js';
|
|
3
|
+
import type { MusicSearchService, MusicSource } from './types.js';
|
|
4
|
+
import type { MusicSearchSessions } from './session.js';
|
|
5
|
+
import type { QrLoginRuntime } from './login/index.js';
|
|
6
|
+
|
|
7
|
+
export interface MusicRuntime {
|
|
8
|
+
readonly credentials: CredentialStore;
|
|
9
|
+
readonly services: Readonly<Record<MusicSource, MusicSearchService>>;
|
|
10
|
+
readonly sessions: MusicSearchSessions;
|
|
11
|
+
readonly logins: QrLoginRuntime;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export const musicRuntimeToken = createToken<MusicRuntime>(
|
|
15
|
+
'zhin.music.runtime',
|
|
16
|
+
'Owner-scoped Music runtime',
|
|
17
|
+
);
|
package/src/session.ts
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import type { MusicInfo, MusicSource } from './types.js';
|
|
2
|
+
|
|
3
|
+
export interface PendingSearch {
|
|
4
|
+
readonly results: readonly MusicInfo[];
|
|
5
|
+
readonly source: MusicSource;
|
|
6
|
+
readonly keyword: string;
|
|
7
|
+
readonly timestamp: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const SEARCH_TIMEOUT_MS = 3 * 60 * 1000;
|
|
11
|
+
|
|
12
|
+
export function sessionKey(
|
|
13
|
+
endpointId: string,
|
|
14
|
+
conversationId: string,
|
|
15
|
+
senderId: string,
|
|
16
|
+
): string {
|
|
17
|
+
return `${endpointId}:${conversationId}:${senderId}`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function resolveMessageIds(input: {
|
|
21
|
+
metadata?: Record<string, unknown>;
|
|
22
|
+
conversation?: { id?: unknown; endpoint?: { id?: unknown } };
|
|
23
|
+
sender?: { id?: unknown };
|
|
24
|
+
}): { endpointId: string; conversationId: string; senderId: string } | null {
|
|
25
|
+
const meta = input.metadata ?? {};
|
|
26
|
+
const endpointId = String(meta.endpointId ?? input.conversation?.endpoint?.id ?? '');
|
|
27
|
+
const conversationId = String(input.conversation?.id ?? '');
|
|
28
|
+
const senderId = String(input.sender?.id ?? '');
|
|
29
|
+
if (!endpointId || !conversationId || !senderId) return null;
|
|
30
|
+
return { endpointId, conversationId, senderId };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Generation-owned pending music selections. */
|
|
34
|
+
export class MusicSearchSessions {
|
|
35
|
+
readonly #pending = new Map<string, PendingSearch>();
|
|
36
|
+
readonly #timeoutMs: number;
|
|
37
|
+
readonly #now: () => number;
|
|
38
|
+
|
|
39
|
+
constructor(
|
|
40
|
+
timeoutMs = SEARCH_TIMEOUT_MS,
|
|
41
|
+
now: () => number = Date.now,
|
|
42
|
+
) {
|
|
43
|
+
this.#timeoutMs = timeoutMs;
|
|
44
|
+
this.#now = now;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
set(key: string, search: PendingSearch): void {
|
|
48
|
+
this.#pending.set(key, search);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
get(key: string): PendingSearch | undefined {
|
|
52
|
+
const session = this.#pending.get(key);
|
|
53
|
+
if (!session) return undefined;
|
|
54
|
+
if (this.#now() - session.timestamp > this.#timeoutMs) {
|
|
55
|
+
this.#pending.delete(key);
|
|
56
|
+
return undefined;
|
|
57
|
+
}
|
|
58
|
+
return session;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
delete(key: string): void {
|
|
62
|
+
this.#pending.delete(key);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
pruneExpired(): void {
|
|
66
|
+
const now = this.#now();
|
|
67
|
+
for (const [key, session] of this.#pending) {
|
|
68
|
+
if (now - session.timestamp > this.#timeoutMs) {
|
|
69
|
+
this.#pending.delete(key);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
dispose(): void {
|
|
75
|
+
this.#pending.clear();
|
|
76
|
+
}
|
|
77
|
+
}
|
package/src/sources/index.ts
CHANGED
|
@@ -1,13 +1,20 @@
|
|
|
1
|
-
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import
|
|
1
|
+
import { QQMusicService } from './qq.js';
|
|
2
|
+
import { NeteaseMusicService } from './netease.js';
|
|
3
|
+
import { KuwoMusicService } from './kuwo.js';
|
|
4
|
+
import { KugouMusicService } from './kugou.js';
|
|
5
|
+
import type { MusicSource, MusicSearchService } from '../types.js';
|
|
6
|
+
import type { CredentialStore } from '../credential-store.js';
|
|
5
7
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
8
|
+
export function createMusicServices(credentials: CredentialStore): Record<MusicSource, MusicSearchService> {
|
|
9
|
+
return {
|
|
10
|
+
qq: new QQMusicService(credentials),
|
|
11
|
+
netease: new NeteaseMusicService(credentials),
|
|
12
|
+
kuwo: new KuwoMusicService(),
|
|
13
|
+
kugou: new KugouMusicService(),
|
|
14
|
+
};
|
|
10
15
|
}
|
|
11
16
|
|
|
12
|
-
export * from './qq.js'
|
|
13
|
-
export * from './netease.js'
|
|
17
|
+
export * from './qq.js';
|
|
18
|
+
export * from './netease.js';
|
|
19
|
+
export * from './kuwo.js';
|
|
20
|
+
export * from './kugou.js';
|