@zhin.js/plugin-music 7.0.8 → 7.0.9

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.
Files changed (97) hide show
  1. package/commands/cookie/delete/[source].js +23 -0
  2. package/commands/cookie/delete/[source].ts +27 -0
  3. package/commands/cookie/get/[source].js +29 -0
  4. package/commands/cookie/get/[source].ts +33 -0
  5. package/commands/cookie/set/[source].js +23 -0
  6. package/commands/cookie/set/[source].ts +27 -0
  7. package/commands/cookie/status.js +25 -0
  8. package/commands/cookie/status.ts +25 -0
  9. package/commands/login/[platform].js +64 -0
  10. package/commands/login/[platform].ts +76 -0
  11. package/commands//347/202/271/346/255/214/[keyword].js +62 -0
  12. package/commands//347/202/271/346/255/214/[keyword].ts +74 -0
  13. package/components/share-music.js +6 -6
  14. package/components/share-music.ts +6 -6
  15. package/lib/config.d.ts +3 -3
  16. package/lib/config.d.ts.map +1 -1
  17. package/lib/config.js +36 -7
  18. package/lib/config.js.map +1 -1
  19. package/lib/credential-store.d.ts +34 -0
  20. package/lib/credential-store.d.ts.map +1 -0
  21. package/lib/credential-store.js +111 -0
  22. package/lib/credential-store.js.map +1 -0
  23. package/lib/index.d.ts +5 -2
  24. package/lib/index.d.ts.map +1 -1
  25. package/lib/index.js +5 -2
  26. package/lib/index.js.map +1 -1
  27. package/lib/login/index.d.ts +20 -0
  28. package/lib/login/index.d.ts.map +1 -0
  29. package/lib/login/index.js +100 -0
  30. package/lib/login/index.js.map +1 -0
  31. package/lib/login/netease.d.ts +6 -0
  32. package/lib/login/netease.d.ts.map +1 -0
  33. package/lib/login/netease.js +58 -0
  34. package/lib/login/netease.js.map +1 -0
  35. package/lib/login/qq.d.ts +7 -0
  36. package/lib/login/qq.d.ts.map +1 -0
  37. package/lib/login/qq.js +139 -0
  38. package/lib/login/qq.js.map +1 -0
  39. package/lib/login/types.d.ts +19 -0
  40. package/lib/login/types.d.ts.map +1 -0
  41. package/lib/login/types.js +11 -0
  42. package/lib/login/types.js.map +1 -0
  43. package/lib/music-lib.d.ts +13 -12
  44. package/lib/music-lib.d.ts.map +1 -1
  45. package/lib/music-lib.js +36 -22
  46. package/lib/music-lib.js.map +1 -1
  47. package/lib/session.d.ts +29 -0
  48. package/lib/session.d.ts.map +1 -0
  49. package/lib/session.js +39 -0
  50. package/lib/session.js.map +1 -0
  51. package/lib/sources/index.d.ts +2 -1
  52. package/lib/sources/index.d.ts.map +1 -1
  53. package/lib/sources/index.js +6 -2
  54. package/lib/sources/index.js.map +1 -1
  55. package/lib/sources/kugou.d.ts +6 -0
  56. package/lib/sources/kugou.d.ts.map +1 -0
  57. package/lib/sources/kugou.js +54 -0
  58. package/lib/sources/kugou.js.map +1 -0
  59. package/lib/sources/kuwo.d.ts +7 -0
  60. package/lib/sources/kuwo.d.ts.map +1 -0
  61. package/lib/sources/kuwo.js +69 -0
  62. package/lib/sources/kuwo.js.map +1 -0
  63. package/lib/sources/netease.d.ts +1 -1
  64. package/lib/sources/netease.d.ts.map +1 -1
  65. package/lib/sources/netease.js +19 -5
  66. package/lib/sources/netease.js.map +1 -1
  67. package/lib/sources/qq.d.ts +1 -14
  68. package/lib/sources/qq.d.ts.map +1 -1
  69. package/lib/sources/qq.js +114 -81
  70. package/lib/sources/qq.js.map +1 -1
  71. package/lib/types.d.ts +4 -24
  72. package/lib/types.d.ts.map +1 -1
  73. package/lib/types.js +0 -1
  74. package/lib/types.js.map +1 -1
  75. package/middlewares/music-selection.js +46 -0
  76. package/middlewares/music-selection.ts +56 -0
  77. package/package.json +10 -5
  78. package/plugin.js +24 -1
  79. package/src/config.ts +54 -30
  80. package/src/credential-store.ts +169 -0
  81. package/src/index.ts +43 -2
  82. package/src/login/index.ts +135 -0
  83. package/src/login/netease.ts +69 -0
  84. package/src/login/qq.ts +154 -0
  85. package/src/login/types.ts +27 -0
  86. package/src/music-lib.ts +39 -26
  87. package/src/session.ts +60 -0
  88. package/src/sources/index.ts +12 -8
  89. package/src/sources/kugou.ts +78 -0
  90. package/src/sources/kuwo.ts +94 -0
  91. package/src/sources/netease.ts +29 -12
  92. package/src/sources/qq.ts +158 -125
  93. package/src/types.ts +6 -28
  94. package/tools/music-search.js +6 -2
  95. package/tools/music-search.ts +6 -2
  96. package/tools/music-share.js +6 -2
  97. package/tools/music-share.ts +6 -2
@@ -0,0 +1,169 @@
1
+ import {
2
+ createGenerationStore,
3
+ type Dispose,
4
+ type GenerationStoreContext,
5
+ } from '@zhin.js/plugin-runtime';
6
+ import type { MusicSource } from './types.js';
7
+
8
+ export const MUSIC_CREDENTIALS_TABLE = 'music_credentials';
9
+
10
+ export interface CredentialRow {
11
+ source: MusicSource;
12
+ key: string;
13
+ value: string;
14
+ updated_at: string;
15
+ }
16
+
17
+ export interface CredentialModel {
18
+ select(...fields: string[]): {
19
+ where(query: Record<string, unknown>): PromiseLike<Record<string, unknown>[]>;
20
+ then<TResult1 = Record<string, unknown>[], TResult2 = never>(
21
+ onfulfilled?:
22
+ | ((value: Record<string, unknown>[]) => TResult1 | PromiseLike<TResult1>)
23
+ | null,
24
+ onrejected?: ((reason: unknown) => TResult2 | PromiseLike<TResult2>) | null,
25
+ ): Promise<TResult1 | TResult2>;
26
+ };
27
+ insert(row: Record<string, unknown>): Promise<unknown>;
28
+ delete(): { where(query: Record<string, unknown>): Promise<unknown> };
29
+ update(patch: Record<string, unknown>): {
30
+ where(query: Record<string, unknown>): Promise<unknown>;
31
+ };
32
+ }
33
+
34
+ export interface CredentialDb {
35
+ models: { get(name: string): CredentialModel | undefined };
36
+ }
37
+
38
+ function createMemoryModel(): CredentialModel {
39
+ const rows: Record<string, unknown>[] = [];
40
+
41
+ function matches(
42
+ row: Record<string, unknown>,
43
+ query: Record<string, unknown>,
44
+ ): boolean {
45
+ return Object.entries(query).every(([k, v]) => row[k] === v);
46
+ }
47
+
48
+ return {
49
+ select: () => {
50
+ const all = rows.map((r) => ({ ...r }));
51
+ return {
52
+ where: async (query) =>
53
+ rows.filter((r) => matches(r, query)).map((r) => ({ ...r })),
54
+ then: (onfulfilled, onrejected) =>
55
+ Promise.resolve(all).then(onfulfilled, onrejected),
56
+ };
57
+ },
58
+ insert: async (row) => {
59
+ rows.push({ ...row });
60
+ },
61
+ delete: () => ({
62
+ where: async (query) => {
63
+ for (let i = rows.length - 1; i >= 0; i--) {
64
+ if (matches(rows[i]!, query)) rows.splice(i, 1);
65
+ }
66
+ },
67
+ }),
68
+ update: (patch) => ({
69
+ where: async (query) => {
70
+ for (const row of rows) {
71
+ if (matches(row, query)) Object.assign(row, patch);
72
+ }
73
+ },
74
+ }),
75
+ };
76
+ }
77
+
78
+ const credentialDbStore = createGenerationStore<CredentialDb>('music/credential-db');
79
+ let _memoryDb: CredentialDb | null = null;
80
+
81
+ export function provideCredentialDb(
82
+ context: GenerationStoreContext,
83
+ db: CredentialDb,
84
+ ): Dispose {
85
+ return credentialDbStore.provide(context, db);
86
+ }
87
+
88
+ function getDb(): CredentialDb {
89
+ const provided = credentialDbStore.tryUse();
90
+ if (provided) return provided;
91
+ if (!_memoryDb) {
92
+ _memoryDb = {
93
+ models: {
94
+ get: (name) =>
95
+ name === MUSIC_CREDENTIALS_TABLE ? memoryModel : undefined,
96
+ },
97
+ };
98
+ }
99
+ return _memoryDb;
100
+ }
101
+
102
+ const memoryModel = createMemoryModel();
103
+
104
+ function getModel(): CredentialModel | null {
105
+ return getDb().models.get(MUSIC_CREDENTIALS_TABLE) ?? null;
106
+ }
107
+
108
+ export async function getCredential(
109
+ source: MusicSource,
110
+ key: string,
111
+ ): Promise<string | null> {
112
+ const model = getModel();
113
+ if (!model) return null;
114
+ const rows = await model.select().where({ source, key });
115
+ if (rows.length === 0) return null;
116
+ return String(rows[0]!.value ?? '');
117
+ }
118
+
119
+ export async function setCredential(
120
+ source: MusicSource,
121
+ key: string,
122
+ value: string,
123
+ ): Promise<void> {
124
+ const model = getModel();
125
+ if (!model) return;
126
+ const existing = await model.select().where({ source, key });
127
+ if (existing.length > 0) {
128
+ await model
129
+ .update({ value, updated_at: new Date().toISOString() })
130
+ .where({ source, key });
131
+ } else {
132
+ await model.insert({
133
+ source,
134
+ key,
135
+ value,
136
+ updated_at: new Date().toISOString(),
137
+ });
138
+ }
139
+ }
140
+
141
+ export async function deleteCredential(
142
+ source: MusicSource,
143
+ key: string,
144
+ ): Promise<void> {
145
+ const model = getModel();
146
+ if (!model) return;
147
+ await model.delete().where({ source, key });
148
+ }
149
+
150
+ export async function listCredentials(
151
+ source?: MusicSource,
152
+ ): Promise<CredentialRow[]> {
153
+ const model = getModel();
154
+ if (!model) return [];
155
+ const rows = source
156
+ ? await model.select().where({ source })
157
+ : await model.select();
158
+ return rows.map((r) => ({
159
+ source: String(r.source) as MusicSource,
160
+ key: String(r.key),
161
+ value: String(r.value),
162
+ updated_at: String(r.updated_at ?? ''),
163
+ }));
164
+ }
165
+
166
+ export function resetCredentialDb(): void {
167
+ credentialDbStore.clear();
168
+ _memoryDb = null;
169
+ }
package/src/index.ts CHANGED
@@ -1,4 +1,45 @@
1
- export { formatDuration, formatMusicInfo, sourceConfigMap } from './config.js';
2
- export { searchMusic, shareMusicDetail } from './music-lib.js';
1
+ export {
2
+ formatDuration,
3
+ formatMusicInfo,
4
+ sourceConfigMap,
5
+ SOURCE_ALIASES,
6
+ SOURCE_DISPLAY_NAME,
7
+ resolveSourceAlias,
8
+ } from './config.js';
9
+ export {
10
+ searchMusic,
11
+ shareMusicDetail,
12
+ formatSearchResults,
13
+ buildMusicShareSegment,
14
+ } from './music-lib.js';
3
15
  export { musicServices } from './sources/index.js';
16
+ export {
17
+ sessionKey,
18
+ resolveMessageIds,
19
+ setPending,
20
+ getPending,
21
+ clearPending,
22
+ cleanExpired,
23
+ type PendingSearch,
24
+ } from './session.js';
25
+ export {
26
+ getCredential,
27
+ setCredential,
28
+ deleteCredential,
29
+ listCredentials,
30
+ MUSIC_CREDENTIALS_TABLE,
31
+ provideCredentialDb,
32
+ resetCredentialDb,
33
+ } from './credential-store.js';
34
+ export {
35
+ startLogin,
36
+ pollLogin,
37
+ cancelLogin,
38
+ getActiveLogin,
39
+ loginSessionKey,
40
+ cleanExpiredLogins,
41
+ type QrLoginSource,
42
+ type QrLoginSession,
43
+ type QrPollResult,
44
+ } from './login/index.js';
4
45
  export type { MusicDetail, MusicInfo, MusicSource, MusicSourceConfig } from './types.js';
@@ -0,0 +1,135 @@
1
+ import type { QrLoginProvider, QrLoginSource, QrPollResult } from './types.js';
2
+ import { QQLoginProvider } from './qq.js';
3
+ import { NeteaseLoginProvider } from './netease.js';
4
+ import { setCredential } from '../credential-store.js';
5
+
6
+ export type { QrLoginSource, QrPollResult, QrLoginProvider } from './types.js';
7
+
8
+ const providers: Record<QrLoginSource, QrLoginProvider> = {
9
+ qq: new QQLoginProvider(),
10
+ netease: new NeteaseLoginProvider(),
11
+ };
12
+
13
+ export interface QrLoginSession {
14
+ source: QrLoginSource;
15
+ pollData: Record<string, string>;
16
+ timestamp: number;
17
+ aborted: boolean;
18
+ }
19
+
20
+ const LOGIN_TIMEOUT_MS = 2 * 60 * 1000;
21
+ const POLL_INTERVAL_MS = 3_000;
22
+ const MAX_POLLS = Math.ceil(LOGIN_TIMEOUT_MS / POLL_INTERVAL_MS);
23
+
24
+ const activeLogins = new Map<string, QrLoginSession>();
25
+
26
+ export function loginSessionKey(
27
+ endpointId: string,
28
+ conversationId: string,
29
+ senderId: string,
30
+ ): string {
31
+ return `login:${endpointId}:${conversationId}:${senderId}`;
32
+ }
33
+
34
+ export function getActiveLogin(key: string): QrLoginSession | undefined {
35
+ const session = activeLogins.get(key);
36
+ if (!session) return undefined;
37
+ if (Date.now() - session.timestamp > LOGIN_TIMEOUT_MS) {
38
+ activeLogins.delete(key);
39
+ return undefined;
40
+ }
41
+ return session;
42
+ }
43
+
44
+ export function cancelLogin(key: string): boolean {
45
+ const session = activeLogins.get(key);
46
+ if (!session) return false;
47
+ session.aborted = true;
48
+ activeLogins.delete(key);
49
+ return true;
50
+ }
51
+
52
+ export async function startLogin(
53
+ source: QrLoginSource,
54
+ key: string,
55
+ ): Promise<{ imageSegment: { type: 'image'; data: Record<string, unknown> } }> {
56
+ const existing = activeLogins.get(key);
57
+ if (existing && !existing.aborted) {
58
+ existing.aborted = true;
59
+ }
60
+
61
+ const provider = providers[source];
62
+ const result = await provider.createQr();
63
+
64
+ activeLogins.set(key, {
65
+ source,
66
+ pollData: result.pollData,
67
+ timestamp: Date.now(),
68
+ aborted: false,
69
+ });
70
+
71
+ return { imageSegment: result.imageSegment };
72
+ }
73
+
74
+ export async function pollLogin(
75
+ key: string,
76
+ onStatus: (result: QrPollResult) => Promise<void>,
77
+ ): Promise<QrPollResult> {
78
+ const session = activeLogins.get(key);
79
+ if (!session) {
80
+ return { status: 'error', message: '没有进行中的登录会话' };
81
+ }
82
+
83
+ const provider = providers[session.source];
84
+ let lastStatus = '';
85
+
86
+ try {
87
+ for (let i = 0; i < MAX_POLLS; i++) {
88
+ if (session.aborted) {
89
+ activeLogins.delete(key);
90
+ return { status: 'error', message: '登录已取消' };
91
+ }
92
+
93
+ const result = await provider.pollQr(session.pollData);
94
+
95
+ if (result.status !== lastStatus) {
96
+ lastStatus = result.status;
97
+ await onStatus(result);
98
+ }
99
+
100
+ if (result.status === 'confirmed') {
101
+ activeLogins.delete(key);
102
+ if (result.cookie) {
103
+ await setCredential(session.source, 'cookie', result.cookie);
104
+ }
105
+ return result;
106
+ }
107
+ if (result.status === 'expired' || result.status === 'error') {
108
+ activeLogins.delete(key);
109
+ return result;
110
+ }
111
+
112
+ await new Promise<void>((r) => setTimeout(r, POLL_INTERVAL_MS));
113
+ }
114
+ } catch (err) {
115
+ console.error('[music-login] poll error:', err);
116
+ activeLogins.delete(key);
117
+ return {
118
+ status: 'error',
119
+ message: `轮询异常:${err instanceof Error ? err.message : String(err)}`,
120
+ };
121
+ }
122
+
123
+ activeLogins.delete(key);
124
+ return { status: 'expired', message: '登录超时,请重试' };
125
+ }
126
+
127
+ export function cleanExpiredLogins(): void {
128
+ const now = Date.now();
129
+ for (const [key, session] of activeLogins) {
130
+ if (now - session.timestamp > LOGIN_TIMEOUT_MS) {
131
+ session.aborted = true;
132
+ activeLogins.delete(key);
133
+ }
134
+ }
135
+ }
@@ -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&timestamp=${timestampMs}`,
11
+ {
12
+ method: 'POST',
13
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
14
+ body: `type=1&timestamp=${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
+ }
@@ -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 CHANGED
@@ -1,5 +1,6 @@
1
1
  import { musicServices } from './sources/index.js';
2
- import type { MusicInfo, MusicSource } from './types.js';
2
+ import { sourceConfigMap, SOURCE_DISPLAY_NAME, formatDuration } from './config.js';
3
+ import type { MusicInfo, MusicDetail, MusicSource } from './types.js';
3
4
 
4
5
  export async function searchMusic(
5
6
  keyword: string,
@@ -8,24 +9,19 @@ export async function searchMusic(
8
9
  ): Promise<{
9
10
  success: true;
10
11
  keyword: string;
11
- results: Array<{ id: string; title: string; source: string; url: string }>;
12
+ source: MusicSource;
13
+ results: MusicInfo[];
12
14
  total: number;
13
15
  }> {
14
- const searchSources: MusicSource[] = source ? [source] : ['qq', 'netease'];
15
- const searchResults = await Promise.all(
16
- searchSources.map((s) => musicServices[s].search(keyword, limit)),
17
- );
18
- const allMusic = searchResults.flat().filter(Boolean) as MusicInfo[];
16
+ const s: MusicSource = source ?? 'qq';
17
+ const service = musicServices[s];
18
+ const results = await service.search(keyword, limit);
19
19
  return {
20
20
  success: true,
21
21
  keyword,
22
- results: allMusic.map((m) => ({
23
- id: m.id,
24
- title: m.title,
25
- source: m.source,
26
- url: m.url,
27
- })),
28
- total: allMusic.length,
22
+ source: s,
23
+ results,
24
+ total: results.length,
29
25
  };
30
26
  }
31
27
 
@@ -36,18 +32,7 @@ export async function shareMusicDetail(id: string, source: MusicSource) {
36
32
  }
37
33
  try {
38
34
  const detail = await service.getDetail(id);
39
- return {
40
- success: true as const,
41
- music: {
42
- id: detail.id,
43
- title: detail.title,
44
- source: detail.source,
45
- url: detail.url,
46
- image: detail.image,
47
- audio: detail.audio,
48
- duration: detail.duration,
49
- },
50
- };
35
+ return { success: true as const, music: detail };
51
36
  } catch (error) {
52
37
  return {
53
38
  success: false as const,
@@ -55,3 +40,31 @@ export async function shareMusicDetail(id: string, source: MusicSource) {
55
40
  };
56
41
  }
57
42
  }
43
+
44
+ export function formatSearchResults(results: readonly MusicInfo[], source: MusicSource): string {
45
+ const sourceName = SOURCE_DISPLAY_NAME[source] ?? source;
46
+ if (results.length === 0) return `[${sourceName}] 未找到相关歌曲`;
47
+ const lines = results.map((m, i) => {
48
+ const parts = [m.title];
49
+ if (m.artist) parts.push(m.artist);
50
+ const duration = m.duration ? ` [${formatDuration(m.duration)}]` : '';
51
+ return `${i + 1}. ${parts.join(' - ')}${duration}`;
52
+ });
53
+ return `[${sourceName}] 搜索结果:\n${lines.join('\n')}\n\n回复序号播放,回复"取消"取消选择`;
54
+ }
55
+
56
+ export function buildMusicShareSegment(detail: MusicDetail) {
57
+ const config = sourceConfigMap[detail.source];
58
+ return {
59
+ type: 'share' as const,
60
+ data: {
61
+ title: detail.title,
62
+ url: detail.url,
63
+ image: detail.image,
64
+ audio: detail.audio,
65
+ duration: detail.duration,
66
+ artist: detail.artist,
67
+ config,
68
+ },
69
+ };
70
+ }