@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.
Files changed (73) hide show
  1. package/README.md +27 -27
  2. package/commands/cookie/delete/{[source].js → [source]/index.js} +4 -4
  3. package/commands/cookie/delete/{[source].ts → [source]/index.ts} +5 -5
  4. package/commands/cookie/get/{[source].js → [source]/index.js} +4 -4
  5. package/commands/cookie/get/{[source].ts → [source]/index.ts} +5 -5
  6. package/commands/cookie/set/{[source].js → [source]/index.js} +4 -4
  7. package/commands/cookie/set/{[source].ts → [source]/index.ts} +5 -5
  8. package/commands/cookie/{status.js → status/index.js} +4 -4
  9. package/commands/cookie/{status.ts → status/index.ts} +5 -5
  10. package/commands/login/{[platform].js → [platform]/index.js} +10 -8
  11. package/commands/login/{[platform].ts → [platform]/index.ts} +10 -12
  12. package/commands//347/202/271/346/255/214/{[keyword].js → [keyword]/index.js} +7 -6
  13. package/commands//347/202/271/346/255/214/{[keyword].ts → [keyword]/index.ts} +8 -7
  14. package/components/{share-music.js → share-music/index.js} +4 -4
  15. package/components/{share-music.ts → share-music/index.ts} +5 -5
  16. package/lib/credential-store.d.ts +10 -7
  17. package/lib/credential-store.d.ts.map +1 -1
  18. package/lib/credential-store.js +44 -70
  19. package/lib/credential-store.js.map +1 -1
  20. package/lib/index.d.ts +5 -4
  21. package/lib/index.d.ts.map +1 -1
  22. package/lib/index.js +5 -4
  23. package/lib/index.js.map +1 -1
  24. package/lib/login/index.d.ts +18 -11
  25. package/lib/login/index.d.ts.map +1 -1
  26. package/lib/login/index.js +103 -79
  27. package/lib/login/index.js.map +1 -1
  28. package/lib/music-lib.d.ts +3 -2
  29. package/lib/music-lib.d.ts.map +1 -1
  30. package/lib/music-lib.js +4 -5
  31. package/lib/music-lib.js.map +1 -1
  32. package/lib/runtime.d.ts +12 -0
  33. package/lib/runtime.d.ts.map +1 -0
  34. package/lib/runtime.js +3 -0
  35. package/lib/runtime.js.map +1 -0
  36. package/lib/session.d.ts +10 -4
  37. package/lib/session.d.ts.map +1 -1
  38. package/lib/session.js +33 -21
  39. package/lib/session.js.map +1 -1
  40. package/lib/sources/index.d.ts +2 -1
  41. package/lib/sources/index.d.ts.map +1 -1
  42. package/lib/sources/index.js +8 -6
  43. package/lib/sources/index.js.map +1 -1
  44. package/lib/sources/kugou.d.ts.map +1 -1
  45. package/lib/sources/kugou.js +16 -1
  46. package/lib/sources/kugou.js.map +1 -1
  47. package/lib/sources/netease.d.ts +3 -0
  48. package/lib/sources/netease.d.ts.map +1 -1
  49. package/lib/sources/netease.js +5 -2
  50. package/lib/sources/netease.js.map +1 -1
  51. package/lib/sources/qq.d.ts +3 -0
  52. package/lib/sources/qq.d.ts.map +1 -1
  53. package/lib/sources/qq.js +6 -3
  54. package/lib/sources/qq.js.map +1 -1
  55. package/middlewares/{music-selection.js → music-selection/index.js} +9 -7
  56. package/middlewares/{music-selection.ts → music-selection/index.ts} +13 -7
  57. package/package.json +16 -11
  58. package/plugin.js +23 -9
  59. package/{agent/skills/music.md → skills/music/SKILL.md} +2 -2
  60. package/{tools/music-search.js → skills/music/tools/music-search/index.js} +4 -3
  61. package/{tools/music-search.ts → skills/music/tools/music-search/index.ts} +11 -5
  62. package/{tools/music-share.js → skills/music/tools/music-share/index.js} +4 -3
  63. package/{tools/music-share.ts → skills/music/tools/music-share/index.ts} +6 -4
  64. package/src/credential-store.ts +43 -87
  65. package/src/index.ts +8 -16
  66. package/src/login/index.ts +111 -92
  67. package/src/music-lib.ts +5 -4
  68. package/src/runtime.ts +17 -0
  69. package/src/session.ts +38 -21
  70. package/src/sources/index.ts +9 -6
  71. package/src/sources/kugou.ts +15 -1
  72. package/src/sources/netease.ts +8 -2
  73. package/src/sources/qq.ts +9 -3
@@ -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
- 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;
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
- const memoryModel = createMemoryModel();
82
+ export class CredentialStore {
83
+ readonly #db: CredentialDb;
103
84
 
104
- function getModel(): CredentialModel | null {
105
- return getDb().models.get(MUSIC_CREDENTIALS_TABLE) ?? null;
106
- }
85
+ constructor(db: CredentialDb) {
86
+ this.#db = db;
87
+ }
107
88
 
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
- }
89
+ private get model(): CredentialModel | null {
90
+ return this.#db.models.get(MUSIC_CREDENTIALS_TABLE) ?? null;
91
+ }
118
92
 
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
- });
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
- 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
- }
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
- 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
- }
110
+ async delete(source: MusicSource, key: string): Promise<void> {
111
+ await this.model?.delete().where({ source, key });
112
+ }
165
113
 
166
- export function resetCredentialDb(): void {
167
- credentialDbStore.clear();
168
- _memoryDb = null;
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 { musicServices } from './sources/index.js';
15
+ export { createMusicServices } from './sources/index.js';
16
16
  export {
17
17
  sessionKey,
18
18
  resolveMessageIds,
19
- setPending,
20
- getPending,
21
- clearPending,
22
- cleanExpired,
19
+ MusicSearchSessions,
23
20
  type PendingSearch,
24
21
  } from './session.js';
25
22
  export {
26
- getCredential,
27
- setCredential,
28
- deleteCredential,
29
- listCredentials,
23
+ CredentialStore,
24
+ createInMemoryCredentialDb,
30
25
  MUSIC_CREDENTIALS_TABLE,
31
- provideCredentialDb,
32
- resetCredentialDb,
26
+ type CredentialDb,
27
+ type CredentialRow,
33
28
  } from './credential-store.js';
29
+ export { musicRuntimeToken, type MusicRuntime } from './runtime.js';
34
30
  export {
35
- startLogin,
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,
@@ -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 { setCredential } from '../credential-store.js';
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
- 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;
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
- 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;
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
- 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: '没有进行中的登录会话' };
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
- const provider = providers[session.source];
84
- let lastStatus = '';
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
- try {
87
- for (let i = 0; i < MAX_POLLS; i++) {
88
- if (session.aborted) {
89
- activeLogins.delete(key);
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
- const result = await provider.pollQr(session.pollData);
94
-
95
- if (result.status !== lastStatus) {
96
- lastStatus = result.status;
97
- await onStatus(result);
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
- if (result.status === 'confirmed') {
101
- activeLogins.delete(key);
102
- if (result.cookie) {
103
- await setCredential(session.source, 'cookie', result.cookie);
112
+ const result = await provider.pollQr(session.pollData);
113
+ if (result.status !== lastStatus) {
114
+ lastStatus = result.status;
115
+ await onStatus(result);
104
116
  }
105
- return result;
106
- }
107
- if (result.status === 'expired' || result.status === 'error') {
108
- activeLogins.delete(key);
109
- return result;
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
- await new Promise<void>((r) => setTimeout(r, POLL_INTERVAL_MS));
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
- activeLogins.delete(key);
124
- return { status: 'expired', message: '登录超时,请重试' };
125
- }
136
+ this.#active.delete(key);
137
+ return { status: 'expired', message: '登录超时,请重试' };
138
+ }
126
139
 
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);
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 = musicServices[s];
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 = musicServices[source];
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
- export function setPending(key: string, search: PendingSearch): void {
36
- pendingSessions.set(key, search);
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
- export function getPending(key: string): PendingSearch | undefined {
40
- const session = pendingSessions.get(key);
41
- if (!session) return undefined;
42
- if (Date.now() - session.timestamp > SEARCH_TIMEOUT_MS) {
43
- pendingSessions.delete(key);
44
- return undefined;
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
- export function clearPending(key: string): void {
50
- pendingSessions.delete(key);
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
- export function cleanExpired(): void {
54
- const now = Date.now();
55
- for (const [key, session] of pendingSessions) {
56
- if (now - session.timestamp > SEARCH_TIMEOUT_MS) {
57
- pendingSessions.delete(key);
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
  }
@@ -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 const musicServices: Record<MusicSource, MusicSearchService> = {
8
- qq: new QQMusicService(),
9
- netease: new NeteaseMusicService(),
10
- kuwo: new KuwoMusicService(),
11
- kugou: new KugouMusicService(),
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';
@@ -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?.replace(/<[^>]+>/g, '') ?? '',
45
+ title: stripHtmlTags(item.songname ?? ''),
32
46
  artist: item.singername,
33
47
  album: item.album_name,
34
48
  duration: item.duration,
@@ -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 { getCredential } from '../credential-store.js';
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 getCredential('netease', 'cookie');
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 { getCredential } from '../credential-store.js';
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 getCredential('qq', 'cookie');
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 getCredential('qq', 'cookie');
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';