@zhin.js/plugin-music 1.1.0 → 1.1.3
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 +27 -27
- package/commands/cookie/delete/{[source].js → [source]/index.js} +4 -4
- package/commands/cookie/delete/{[source].ts → [source]/index.ts} +5 -5
- package/commands/cookie/get/{[source].js → [source]/index.js} +4 -4
- package/commands/cookie/get/{[source].ts → [source]/index.ts} +5 -5
- package/commands/cookie/set/{[source].js → [source]/index.js} +4 -4
- package/commands/cookie/set/{[source].ts → [source]/index.ts} +5 -5
- package/commands/cookie/{status.js → status/index.js} +4 -4
- package/commands/cookie/{status.ts → status/index.ts} +5 -5
- package/commands/login/{[platform].js → [platform]/index.js} +10 -8
- package/commands/login/{[platform].ts → [platform]/index.ts} +10 -12
- package/commands//347/202/271/346/255/214/{[keyword].js → [keyword]/index.js} +7 -6
- package/commands//347/202/271/346/255/214/{[keyword].ts → [keyword]/index.ts} +8 -7
- package/components/{share-music.js → share-music/index.js} +4 -4
- package/components/{share-music.ts → share-music/index.ts} +5 -5
- package/lib/credential-store.d.ts +10 -7
- package/lib/credential-store.d.ts.map +1 -1
- package/lib/credential-store.js +44 -70
- package/lib/credential-store.js.map +1 -1
- package/lib/index.d.ts +5 -4
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +5 -4
- package/lib/index.js.map +1 -1
- package/lib/login/index.d.ts +18 -11
- package/lib/login/index.d.ts.map +1 -1
- package/lib/login/index.js +103 -79
- package/lib/login/index.js.map +1 -1
- package/lib/music-lib.d.ts +3 -2
- package/lib/music-lib.d.ts.map +1 -1
- package/lib/music-lib.js +4 -5
- package/lib/music-lib.js.map +1 -1
- 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 +10 -4
- package/lib/session.d.ts.map +1 -1
- package/lib/session.js +33 -21
- package/lib/session.js.map +1 -1
- package/lib/sources/index.d.ts +2 -1
- package/lib/sources/index.d.ts.map +1 -1
- package/lib/sources/index.js +8 -6
- package/lib/sources/index.js.map +1 -1
- package/lib/sources/kugou.d.ts.map +1 -1
- package/lib/sources/kugou.js +16 -1
- package/lib/sources/kugou.js.map +1 -1
- package/lib/sources/netease.d.ts +3 -0
- package/lib/sources/netease.d.ts.map +1 -1
- package/lib/sources/netease.js +5 -2
- package/lib/sources/netease.js.map +1 -1
- package/lib/sources/qq.d.ts +3 -0
- package/lib/sources/qq.d.ts.map +1 -1
- package/lib/sources/qq.js +6 -3
- package/lib/sources/qq.js.map +1 -1
- package/middlewares/{music-selection.js → music-selection/index.js} +9 -7
- package/middlewares/{music-selection.ts → music-selection/index.ts} +13 -7
- package/package.json +16 -11
- package/plugin.js +23 -9
- package/{agent/skills/music.md → skills/music/SKILL.md} +2 -2
- package/{tools/music-search.js → skills/music/tools/music-search/index.js} +4 -3
- package/{tools/music-search.ts → skills/music/tools/music-search/index.ts} +11 -5
- package/{tools/music-share.js → skills/music/tools/music-share/index.js} +4 -3
- package/{tools/music-share.ts → skills/music/tools/music-share/index.ts} +6 -4
- package/src/credential-store.ts +43 -87
- package/src/index.ts +8 -16
- package/src/login/index.ts +111 -92
- package/src/music-lib.ts +5 -4
- package/src/runtime.ts +17 -0
- package/src/session.ts +38 -21
- package/src/sources/index.ts +9 -6
- package/src/sources/kugou.ts +15 -1
- package/src/sources/netease.ts +8 -2
- package/src/sources/qq.ts +9 -3
package/src/credential-store.ts
CHANGED
|
@@ -1,8 +1,3 @@
|
|
|
1
|
-
import {
|
|
2
|
-
createGenerationStore,
|
|
3
|
-
type Dispose,
|
|
4
|
-
type GenerationStoreContext,
|
|
5
|
-
} from 'zhin.js';
|
|
6
1
|
import type { MusicSource } from './types.js';
|
|
7
2
|
|
|
8
3
|
export const MUSIC_CREDENTIALS_TABLE = 'music_credentials';
|
|
@@ -75,95 +70,56 @@ function createMemoryModel(): CredentialModel {
|
|
|
75
70
|
};
|
|
76
71
|
}
|
|
77
72
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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;
|
|
73
|
+
export function createInMemoryCredentialDb(): CredentialDb {
|
|
74
|
+
const model = createMemoryModel();
|
|
75
|
+
return {
|
|
76
|
+
models: {
|
|
77
|
+
get: (name) => name === MUSIC_CREDENTIALS_TABLE ? model : undefined,
|
|
78
|
+
},
|
|
79
|
+
};
|
|
100
80
|
}
|
|
101
81
|
|
|
102
|
-
|
|
82
|
+
export class CredentialStore {
|
|
83
|
+
readonly #db: CredentialDb;
|
|
103
84
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
}
|
|
85
|
+
constructor(db: CredentialDb) {
|
|
86
|
+
this.#db = db;
|
|
87
|
+
}
|
|
107
88
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
}
|
|
89
|
+
private get model(): CredentialModel | null {
|
|
90
|
+
return this.#db.models.get(MUSIC_CREDENTIALS_TABLE) ?? null;
|
|
91
|
+
}
|
|
118
92
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
});
|
|
93
|
+
async get(source: MusicSource, key: string): Promise<string | null> {
|
|
94
|
+
const rows = await this.model?.select().where({ source, key });
|
|
95
|
+
if (!rows?.length) return null;
|
|
96
|
+
return String(rows[0]!.value ?? '');
|
|
138
97
|
}
|
|
139
|
-
}
|
|
140
98
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
)
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
}
|
|
99
|
+
async set(source: MusicSource, key: string, value: string): Promise<void> {
|
|
100
|
+
const model = this.model;
|
|
101
|
+
if (!model) return;
|
|
102
|
+
const existing = await model.select().where({ source, key });
|
|
103
|
+
if (existing.length > 0) {
|
|
104
|
+
await model.update({ value, updated_at: new Date().toISOString() }).where({ source, key });
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
await model.insert({ source, key, value, updated_at: new Date().toISOString() });
|
|
108
|
+
}
|
|
149
109
|
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
-
}
|
|
110
|
+
async delete(source: MusicSource, key: string): Promise<void> {
|
|
111
|
+
await this.model?.delete().where({ source, key });
|
|
112
|
+
}
|
|
165
113
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
114
|
+
async list(source?: MusicSource): Promise<CredentialRow[]> {
|
|
115
|
+
const model = this.model;
|
|
116
|
+
if (!model) return [];
|
|
117
|
+
const rows = source ? await model.select().where({ source }) : await model.select();
|
|
118
|
+
return rows.map((row) => ({
|
|
119
|
+
source: String(row.source) as MusicSource,
|
|
120
|
+
key: String(row.key),
|
|
121
|
+
value: String(row.value),
|
|
122
|
+
updated_at: String(row.updated_at ?? ''),
|
|
123
|
+
}));
|
|
124
|
+
}
|
|
169
125
|
}
|
package/src/index.ts
CHANGED
|
@@ -12,32 +12,24 @@ export {
|
|
|
12
12
|
formatSearchResults,
|
|
13
13
|
buildMusicShareSegment,
|
|
14
14
|
} from './music-lib.js';
|
|
15
|
-
export {
|
|
15
|
+
export { createMusicServices } from './sources/index.js';
|
|
16
16
|
export {
|
|
17
17
|
sessionKey,
|
|
18
18
|
resolveMessageIds,
|
|
19
|
-
|
|
20
|
-
getPending,
|
|
21
|
-
clearPending,
|
|
22
|
-
cleanExpired,
|
|
19
|
+
MusicSearchSessions,
|
|
23
20
|
type PendingSearch,
|
|
24
21
|
} from './session.js';
|
|
25
22
|
export {
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
deleteCredential,
|
|
29
|
-
listCredentials,
|
|
23
|
+
CredentialStore,
|
|
24
|
+
createInMemoryCredentialDb,
|
|
30
25
|
MUSIC_CREDENTIALS_TABLE,
|
|
31
|
-
|
|
32
|
-
|
|
26
|
+
type CredentialDb,
|
|
27
|
+
type CredentialRow,
|
|
33
28
|
} from './credential-store.js';
|
|
29
|
+
export { musicRuntimeToken, type MusicRuntime } from './runtime.js';
|
|
34
30
|
export {
|
|
35
|
-
|
|
36
|
-
pollLogin,
|
|
37
|
-
cancelLogin,
|
|
38
|
-
getActiveLogin,
|
|
31
|
+
QrLoginRuntime,
|
|
39
32
|
loginSessionKey,
|
|
40
|
-
cleanExpiredLogins,
|
|
41
33
|
type QrLoginSource,
|
|
42
34
|
type QrLoginSession,
|
|
43
35
|
type QrPollResult,
|
package/src/login/index.ts
CHANGED
|
@@ -1,15 +1,10 @@
|
|
|
1
1
|
import type { QrLoginProvider, QrLoginSource, QrPollResult } from './types.js';
|
|
2
2
|
import { QQLoginProvider } from './qq.js';
|
|
3
3
|
import { NeteaseLoginProvider } from './netease.js';
|
|
4
|
-
import {
|
|
4
|
+
import type { CredentialStore } from '../credential-store.js';
|
|
5
5
|
|
|
6
6
|
export type { QrLoginSource, QrPollResult, QrLoginProvider } from './types.js';
|
|
7
7
|
|
|
8
|
-
const providers: Record<QrLoginSource, QrLoginProvider> = {
|
|
9
|
-
qq: new QQLoginProvider(),
|
|
10
|
-
netease: new NeteaseLoginProvider(),
|
|
11
|
-
};
|
|
12
|
-
|
|
13
8
|
export interface QrLoginSession {
|
|
14
9
|
source: QrLoginSource;
|
|
15
10
|
pollData: Record<string, string>;
|
|
@@ -21,8 +16,6 @@ const LOGIN_TIMEOUT_MS = 2 * 60 * 1000;
|
|
|
21
16
|
const POLL_INTERVAL_MS = 3_000;
|
|
22
17
|
const MAX_POLLS = Math.ceil(LOGIN_TIMEOUT_MS / POLL_INTERVAL_MS);
|
|
23
18
|
|
|
24
|
-
const activeLogins = new Map<string, QrLoginSession>();
|
|
25
|
-
|
|
26
19
|
export function loginSessionKey(
|
|
27
20
|
endpointId: string,
|
|
28
21
|
conversationId: string,
|
|
@@ -31,105 +24,131 @@ export function loginSessionKey(
|
|
|
31
24
|
return `login:${endpointId}:${conversationId}:${senderId}`;
|
|
32
25
|
}
|
|
33
26
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
+
};
|
|
40
48
|
}
|
|
41
|
-
return session;
|
|
42
|
-
}
|
|
43
49
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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;
|
|
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
59
|
}
|
|
60
60
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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: '没有进行中的登录会话' };
|
|
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;
|
|
81
67
|
}
|
|
82
68
|
|
|
83
|
-
|
|
84
|
-
|
|
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);
|
|
85
82
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
if (session.aborted) {
|
|
89
|
-
|
|
90
|
-
return { status: 'error', message: '登录已取消' };
|
|
83
|
+
try {
|
|
84
|
+
const result = await this.#providers[source].createQr();
|
|
85
|
+
if (session.aborted || this.#active.get(key) !== session) {
|
|
86
|
+
throw new Error('登录已取消');
|
|
91
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
|
+
}
|
|
92
95
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
+
}
|
|
99
111
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
await
|
|
112
|
+
const result = await provider.pollQr(session.pollData);
|
|
113
|
+
if (result.status !== lastStatus) {
|
|
114
|
+
lastStatus = result.status;
|
|
115
|
+
await onStatus(result);
|
|
104
116
|
}
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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);
|
|
110
127
|
}
|
|
111
|
-
|
|
112
|
-
|
|
128
|
+
} catch (error) {
|
|
129
|
+
this.#active.delete(key);
|
|
130
|
+
return {
|
|
131
|
+
status: 'error',
|
|
132
|
+
message: `轮询异常:${error instanceof Error ? error.message : String(error)}`,
|
|
133
|
+
};
|
|
113
134
|
}
|
|
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
135
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
}
|
|
136
|
+
this.#active.delete(key);
|
|
137
|
+
return { status: 'expired', message: '登录超时,请重试' };
|
|
138
|
+
}
|
|
126
139
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
+
}
|
|
133
147
|
}
|
|
134
148
|
}
|
|
149
|
+
|
|
150
|
+
dispose(): void {
|
|
151
|
+
for (const session of this.#active.values()) session.aborted = true;
|
|
152
|
+
this.#active.clear();
|
|
153
|
+
}
|
|
135
154
|
}
|
package/src/music-lib.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import { musicServices } from './sources/index.js';
|
|
2
1
|
import { sourceConfigMap, SOURCE_DISPLAY_NAME, formatDuration } from './config.js';
|
|
3
2
|
import type { MusicInfo, MusicDetail, MusicSource } from './types.js';
|
|
3
|
+
import type { MusicRuntime } from './runtime.js';
|
|
4
4
|
|
|
5
5
|
export async function searchMusic(
|
|
6
|
+
runtime: MusicRuntime,
|
|
6
7
|
keyword: string,
|
|
7
8
|
source?: MusicSource,
|
|
8
9
|
limit = 5,
|
|
@@ -14,7 +15,7 @@ export async function searchMusic(
|
|
|
14
15
|
total: number;
|
|
15
16
|
}> {
|
|
16
17
|
const s: MusicSource = source ?? 'qq';
|
|
17
|
-
const service =
|
|
18
|
+
const service = runtime.services[s];
|
|
18
19
|
const results = await service.search(keyword, limit);
|
|
19
20
|
return {
|
|
20
21
|
success: true,
|
|
@@ -25,8 +26,8 @@ export async function searchMusic(
|
|
|
25
26
|
};
|
|
26
27
|
}
|
|
27
28
|
|
|
28
|
-
export async function shareMusicDetail(id: string, source: MusicSource) {
|
|
29
|
-
const service =
|
|
29
|
+
export async function shareMusicDetail(runtime: MusicRuntime, id: string, source: MusicSource) {
|
|
30
|
+
const service = runtime.services[source];
|
|
30
31
|
if (!service) {
|
|
31
32
|
return { success: false as const, error: `不支持的音乐源: ${source}` };
|
|
32
33
|
}
|
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
CHANGED
|
@@ -9,8 +9,6 @@ export interface PendingSearch {
|
|
|
9
9
|
|
|
10
10
|
const SEARCH_TIMEOUT_MS = 3 * 60 * 1000;
|
|
11
11
|
|
|
12
|
-
const pendingSessions = new Map<string, PendingSearch>();
|
|
13
|
-
|
|
14
12
|
export function sessionKey(
|
|
15
13
|
endpointId: string,
|
|
16
14
|
conversationId: string,
|
|
@@ -32,29 +30,48 @@ export function resolveMessageIds(input: {
|
|
|
32
30
|
return { endpointId, conversationId, senderId };
|
|
33
31
|
}
|
|
34
32
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
38
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
39
|
+
constructor(
|
|
40
|
+
timeoutMs = SEARCH_TIMEOUT_MS,
|
|
41
|
+
now: () => number = Date.now,
|
|
42
|
+
) {
|
|
43
|
+
this.#timeoutMs = timeoutMs;
|
|
44
|
+
this.#now = now;
|
|
45
45
|
}
|
|
46
|
-
return session;
|
|
47
|
-
}
|
|
48
46
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
}
|
|
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
|
+
}
|
|
52
64
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
+
}
|
|
58
71
|
}
|
|
59
72
|
}
|
|
73
|
+
|
|
74
|
+
dispose(): void {
|
|
75
|
+
this.#pending.clear();
|
|
76
|
+
}
|
|
60
77
|
}
|
package/src/sources/index.ts
CHANGED
|
@@ -3,13 +3,16 @@ import { NeteaseMusicService } from './netease.js';
|
|
|
3
3
|
import { KuwoMusicService } from './kuwo.js';
|
|
4
4
|
import { KugouMusicService } from './kugou.js';
|
|
5
5
|
import type { MusicSource, MusicSearchService } from '../types.js';
|
|
6
|
+
import type { CredentialStore } from '../credential-store.js';
|
|
6
7
|
|
|
7
|
-
export
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
+
};
|
|
15
|
+
}
|
|
13
16
|
|
|
14
17
|
export * from './qq.js';
|
|
15
18
|
export * from './netease.js';
|
package/src/sources/kugou.ts
CHANGED
|
@@ -9,6 +9,20 @@ interface KugouSearchItem {
|
|
|
9
9
|
album_id?: string;
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
+
function stripHtmlTags(input: string): string {
|
|
13
|
+
let result = '';
|
|
14
|
+
let cursor = 0;
|
|
15
|
+
while (cursor < input.length) {
|
|
16
|
+
const start = input.indexOf('<', cursor);
|
|
17
|
+
if (start < 0) return result + input.slice(cursor);
|
|
18
|
+
result += input.slice(cursor, start);
|
|
19
|
+
const end = input.indexOf('>', start + 1);
|
|
20
|
+
if (end < 0) return result + input.slice(start);
|
|
21
|
+
cursor = end + 1;
|
|
22
|
+
}
|
|
23
|
+
return result;
|
|
24
|
+
}
|
|
25
|
+
|
|
12
26
|
export class KugouMusicService implements MusicSearchService {
|
|
13
27
|
async search(keyword: string, limit = 10): Promise<MusicInfo[]> {
|
|
14
28
|
try {
|
|
@@ -28,7 +42,7 @@ export class KugouMusicService implements MusicSearchService {
|
|
|
28
42
|
return items.slice(0, limit).map((item) => ({
|
|
29
43
|
id: item.hash,
|
|
30
44
|
source: 'kugou' as const,
|
|
31
|
-
title: item.songname
|
|
45
|
+
title: stripHtmlTags(item.songname ?? ''),
|
|
32
46
|
artist: item.singername,
|
|
33
47
|
album: item.album_name,
|
|
34
48
|
duration: item.duration,
|
package/src/sources/netease.ts
CHANGED
|
@@ -1,9 +1,15 @@
|
|
|
1
1
|
// plugins/utils/music/src/sources/netease.ts
|
|
2
2
|
import type { MusicSearchService, MusicDetail, MusicInfo, Music163 } from '../types.js';
|
|
3
|
-
import {
|
|
3
|
+
import type { CredentialStore } from '../credential-store.js';
|
|
4
4
|
|
|
5
5
|
/** 网易云音乐搜索服务 */
|
|
6
6
|
export class NeteaseMusicService implements MusicSearchService {
|
|
7
|
+
readonly #credentials: CredentialStore;
|
|
8
|
+
|
|
9
|
+
constructor(credentials: CredentialStore) {
|
|
10
|
+
this.#credentials = credentials;
|
|
11
|
+
}
|
|
12
|
+
|
|
7
13
|
async search(keyword: string, limit = 10): Promise<MusicInfo[]> {
|
|
8
14
|
try {
|
|
9
15
|
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}`
|
|
@@ -72,7 +78,7 @@ export class NeteaseMusicService implements MusicSearchService {
|
|
|
72
78
|
* @returns 音频直链 URL
|
|
73
79
|
*/
|
|
74
80
|
async getAudioUrl(id: string): Promise<string> {
|
|
75
|
-
const cookie = await
|
|
81
|
+
const cookie = await this.#credentials.get('netease', 'cookie');
|
|
76
82
|
if (cookie) {
|
|
77
83
|
try {
|
|
78
84
|
const response = await fetch(
|
package/src/sources/qq.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import * as crypto from 'node:crypto';
|
|
2
2
|
import type { MusicSearchService, MusicDetail, MusicInfo } from '../types.js';
|
|
3
|
-
import {
|
|
3
|
+
import type { CredentialStore } from '../credential-store.js';
|
|
4
4
|
|
|
5
5
|
const md5 = (text: string) => crypto.createHash('md5').update(text).digest('hex');
|
|
6
6
|
|
|
@@ -15,9 +15,15 @@ function getHeaders(cookie?: string | null) {
|
|
|
15
15
|
}
|
|
16
16
|
|
|
17
17
|
export class QQMusicService implements MusicSearchService {
|
|
18
|
+
readonly #credentials: CredentialStore;
|
|
19
|
+
|
|
20
|
+
constructor(credentials: CredentialStore) {
|
|
21
|
+
this.#credentials = credentials;
|
|
22
|
+
}
|
|
23
|
+
|
|
18
24
|
async search(keyword: string, limit = 10): Promise<MusicInfo[]> {
|
|
19
25
|
try {
|
|
20
|
-
const cookie = await
|
|
26
|
+
const cookie = await this.#credentials.get('qq', 'cookie');
|
|
21
27
|
const response = await fetch(MUSICU_API, {
|
|
22
28
|
method: 'POST',
|
|
23
29
|
headers: getHeaders(cookie),
|
|
@@ -108,7 +114,7 @@ export class QQMusicService implements MusicSearchService {
|
|
|
108
114
|
}
|
|
109
115
|
|
|
110
116
|
async getAudioUrl(id: string, mid?: string, mediaMid?: string): Promise<string> {
|
|
111
|
-
const cookie = await
|
|
117
|
+
const cookie = await this.#credentials.get('qq', 'cookie');
|
|
112
118
|
if (cookie) {
|
|
113
119
|
try {
|
|
114
120
|
const uin = /uin=o?(\d+)/.exec(cookie)?.[1] ?? '0';
|