@zhin.js/plugin-music 1.0.25 → 1.1.0
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 +1 -0
- package/commands/cookie/delete/[source].js +23 -0
- package/commands/cookie/delete/[source].ts +27 -0
- package/commands/cookie/get/[source].js +29 -0
- package/commands/cookie/get/[source].ts +33 -0
- package/commands/cookie/set/[source].js +23 -0
- package/commands/cookie/set/[source].ts +27 -0
- package/commands/cookie/status.js +25 -0
- package/commands/cookie/status.ts +25 -0
- package/commands/login/[platform].js +64 -0
- package/commands/login/[platform].ts +76 -0
- package/commands//347/202/271/346/255/214/[keyword].js +62 -0
- package/commands//347/202/271/346/255/214/[keyword].ts +74 -0
- package/components/share-music.js +24 -0
- package/components/share-music.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 +34 -0
- package/lib/credential-store.d.ts.map +1 -0
- package/lib/credential-store.js +111 -0
- package/lib/credential-store.js.map +1 -0
- package/lib/index.d.ts +7 -1
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +6 -112
- package/lib/index.js.map +1 -1
- package/lib/login/index.d.ts +20 -0
- package/lib/login/index.d.ts.map +1 -0
- package/lib/login/index.js +100 -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 +31 -0
- package/lib/music-lib.d.ts.map +1 -0
- package/lib/music-lib.js +59 -0
- package/lib/music-lib.js.map +1 -0
- package/lib/session.d.ts +29 -0
- package/lib/session.d.ts.map +1 -0
- package/lib/session.js +39 -0
- package/lib/session.js.map +1 -0
- package/lib/sources/index.d.ts +2 -1
- package/lib/sources/index.d.ts.map +1 -1
- package/lib/sources/index.js +6 -2
- 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 +54 -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 +1 -1
- package/lib/sources/netease.d.ts.map +1 -1
- package/lib/sources/netease.js +22 -6
- package/lib/sources/netease.js.map +1 -1
- package/lib/sources/qq.d.ts +1 -14
- package/lib/sources/qq.d.ts.map +1 -1
- package/lib/sources/qq.js +114 -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.js +46 -0
- package/middlewares/music-selection.ts +56 -0
- package/package.json +62 -13
- package/plugin.js +31 -0
- package/schema.json +6 -0
- package/src/config.ts +54 -30
- package/src/credential-store.ts +169 -0
- package/src/index.ts +45 -0
- package/src/login/index.ts +135 -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 +70 -0
- package/src/session.ts +60 -0
- package/src/sources/index.ts +12 -8
- package/src/sources/kugou.ts +78 -0
- package/src/sources/kuwo.ts +94 -0
- package/src/sources/netease.ts +29 -11
- package/src/sources/qq.ts +158 -126
- package/src/types.ts +6 -28
- package/tools/music-search.js +21 -0
- package/tools/music-search.ts +27 -0
- package/tools/music-share.js +20 -0
- package/tools/music-share.ts +24 -0
- 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
- /package/{skills/music/SKILL.md → agent/skills/music.md} +0 -0
package/package.json
CHANGED
|
@@ -1,24 +1,28 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhin.js/plugin-music",
|
|
3
|
-
"version": "1.0
|
|
4
|
-
"description": "Music search and sharing plugin for Zhin.js",
|
|
3
|
+
"version": "1.1.0",
|
|
4
|
+
"description": "Music search and sharing plugin for Zhin.js (Plugin Runtime)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./lib/index.js",
|
|
7
7
|
"types": "./lib/index.d.ts",
|
|
8
8
|
"exports": {
|
|
9
9
|
".": {
|
|
10
10
|
"types": "./lib/index.d.ts",
|
|
11
|
-
"development": "./src/index.
|
|
11
|
+
"development": "./src/index.ts",
|
|
12
12
|
"import": "./lib/index.js"
|
|
13
13
|
},
|
|
14
14
|
"./package.json": "./package.json"
|
|
15
15
|
},
|
|
16
16
|
"files": [
|
|
17
|
+
"plugin.js",
|
|
18
|
+
"schema.json",
|
|
19
|
+
"commands",
|
|
20
|
+
"middlewares",
|
|
21
|
+
"components",
|
|
22
|
+
"tools",
|
|
17
23
|
"src",
|
|
18
24
|
"lib",
|
|
19
|
-
"
|
|
20
|
-
"dist",
|
|
21
|
-
"skills",
|
|
25
|
+
"agent",
|
|
22
26
|
"README.md"
|
|
23
27
|
],
|
|
24
28
|
"keywords": [
|
|
@@ -38,19 +42,34 @@
|
|
|
38
42
|
"url": "https://github.com/lc-cn"
|
|
39
43
|
},
|
|
40
44
|
"license": "MIT",
|
|
41
|
-
"dependencies": {
|
|
45
|
+
"dependencies": {
|
|
46
|
+
"@zhin.js/core": "1.1.35",
|
|
47
|
+
"@zhin.js/tool": "1.1.0"
|
|
48
|
+
},
|
|
42
49
|
"devDependencies": {
|
|
43
50
|
"typescript": "^6.0.3",
|
|
44
|
-
"
|
|
45
|
-
"
|
|
51
|
+
"vitest": "^4.1.10",
|
|
52
|
+
"zhin.js": "1.1.0"
|
|
46
53
|
},
|
|
47
54
|
"peerDependencies": {
|
|
48
|
-
"@zhin.js/adapter-icqq": "
|
|
49
|
-
"zhin.js": "1.0
|
|
55
|
+
"@zhin.js/adapter-icqq": "^1.1.0",
|
|
56
|
+
"@zhin.js/command": "^1.1.0",
|
|
57
|
+
"@zhin.js/component": "^1.1.0",
|
|
58
|
+
"@zhin.js/middleware": "^1.1.0",
|
|
59
|
+
"zhin.js": "^1.1.0"
|
|
50
60
|
},
|
|
51
61
|
"peerDependenciesMeta": {
|
|
52
62
|
"@zhin.js/adapter-icqq": {
|
|
53
63
|
"optional": true
|
|
64
|
+
},
|
|
65
|
+
"@zhin.js/command": {
|
|
66
|
+
"optional": true
|
|
67
|
+
},
|
|
68
|
+
"@zhin.js/component": {
|
|
69
|
+
"optional": true
|
|
70
|
+
},
|
|
71
|
+
"@zhin.js/middleware": {
|
|
72
|
+
"optional": true
|
|
54
73
|
}
|
|
55
74
|
},
|
|
56
75
|
"repository": {
|
|
@@ -62,8 +81,38 @@
|
|
|
62
81
|
"access": "public",
|
|
63
82
|
"registry": "https://registry.npmjs.org"
|
|
64
83
|
},
|
|
84
|
+
"engines": {
|
|
85
|
+
"node": "^20.19.0 || >=22.12.0"
|
|
86
|
+
},
|
|
87
|
+
"zhin": {
|
|
88
|
+
"protocol": 1,
|
|
89
|
+
"type": "plugin",
|
|
90
|
+
"entry": "./plugin.js",
|
|
91
|
+
"engine": "^1.0.0",
|
|
92
|
+
"runtime": "trusted",
|
|
93
|
+
"features": [
|
|
94
|
+
{
|
|
95
|
+
"package": "@zhin.js/command",
|
|
96
|
+
"api": "^1.0.0"
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
"package": "@zhin.js/middleware",
|
|
100
|
+
"api": "^1.0.0"
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
"package": "@zhin.js/component",
|
|
104
|
+
"api": "^1.0.0"
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
"package": "@zhin.js/tool",
|
|
108
|
+
"api": "^1.0.0"
|
|
109
|
+
}
|
|
110
|
+
],
|
|
111
|
+
"plugins": []
|
|
112
|
+
},
|
|
65
113
|
"scripts": {
|
|
66
|
-
"build": "tsc
|
|
67
|
-
"clean": "rimraf lib"
|
|
114
|
+
"build": "tsc",
|
|
115
|
+
"clean": "rimraf lib",
|
|
116
|
+
"test": "NODE_OPTIONS=--experimental-strip-types vitest run --root ../../.. plugins/utils/music/tests"
|
|
68
117
|
}
|
|
69
118
|
}
|
package/plugin.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// Generated by build-plugin-runtime-entries.mjs. Do not edit.
|
|
2
|
+
import { definePlugin, databaseHostToken } from 'zhin.js';
|
|
3
|
+
import { cleanExpired } from "./lib/session.js";
|
|
4
|
+
import { cleanExpiredLogins } from "./lib/login/index.js";
|
|
5
|
+
import { MUSIC_CREDENTIALS_TABLE, provideCredentialDb, } from "./lib/credential-store.js";
|
|
6
|
+
function defineCredentialTable(db) {
|
|
7
|
+
db.define(MUSIC_CREDENTIALS_TABLE, {
|
|
8
|
+
source: { type: 'text', nullable: false },
|
|
9
|
+
key: { type: 'text', nullable: false },
|
|
10
|
+
value: { type: 'text', nullable: false },
|
|
11
|
+
updated_at: { type: 'text', default: '' },
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
export default definePlugin({
|
|
15
|
+
name: 'music',
|
|
16
|
+
metadata: {
|
|
17
|
+
displayName: 'Music',
|
|
18
|
+
},
|
|
19
|
+
setup(context) {
|
|
20
|
+
if (context.resources.has(databaseHostToken)) {
|
|
21
|
+
const host = context.resources.use(databaseHostToken);
|
|
22
|
+
defineCredentialTable(host);
|
|
23
|
+
provideCredentialDb(context, host);
|
|
24
|
+
}
|
|
25
|
+
const timer = setInterval(() => {
|
|
26
|
+
cleanExpired();
|
|
27
|
+
cleanExpiredLogins();
|
|
28
|
+
}, 60_000);
|
|
29
|
+
return () => clearInterval(timer);
|
|
30
|
+
},
|
|
31
|
+
});
|
package/schema.json
ADDED
package/src/config.ts
CHANGED
|
@@ -1,7 +1,5 @@
|
|
|
1
|
-
|
|
2
|
-
import type { MusicSourceConfig,MusicSource } from './types.js'
|
|
1
|
+
import type { MusicSourceConfig, MusicSource } from './types.js';
|
|
3
2
|
|
|
4
|
-
/** 音乐源配置映射 */
|
|
5
3
|
export const sourceConfigMap: Record<MusicSource, MusicSourceConfig> = {
|
|
6
4
|
qq: {
|
|
7
5
|
appid: 100497308,
|
|
@@ -17,38 +15,64 @@ export const sourceConfigMap: Record<MusicSource, MusicSourceConfig> = {
|
|
|
17
15
|
sign: 'da6b069da1e2982db3e386233f68d76d',
|
|
18
16
|
version: '9.1.92',
|
|
19
17
|
},
|
|
18
|
+
kuwo: {
|
|
19
|
+
appid: 100243533,
|
|
20
|
+
package: 'cn.kuwo.player',
|
|
21
|
+
icon: 'https://p.qpic.cn/qqconnect/0/app_100243533_1636498875/100?max-age=2592000&t=0',
|
|
22
|
+
sign: 'bf9ff4ffb4c558a34ee3fd52c223ebf5',
|
|
23
|
+
version: '10.1.3.5',
|
|
24
|
+
},
|
|
25
|
+
kugou: {
|
|
26
|
+
appid: 205141,
|
|
27
|
+
package: 'com.kugou.android',
|
|
28
|
+
icon: 'https://open.gtimg.cn/open/app_icon/00/20/51/41/205141_100_m.png',
|
|
29
|
+
sign: '5a84ee451a90ca88a1ea0ad2bef9e69c',
|
|
30
|
+
version: '12.2.8',
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export const SOURCE_ALIASES: Record<string, MusicSource> = {
|
|
35
|
+
'qq': 'qq',
|
|
36
|
+
'QQ': 'qq',
|
|
37
|
+
'QQ音乐': 'qq',
|
|
38
|
+
'qq音乐': 'qq',
|
|
39
|
+
'网易': 'netease',
|
|
40
|
+
'网易云': 'netease',
|
|
41
|
+
'163': 'netease',
|
|
42
|
+
'酷我': 'kuwo',
|
|
43
|
+
'酷狗': 'kugou',
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export const SOURCE_DISPLAY_NAME: Record<MusicSource, string> = {
|
|
47
|
+
qq: 'QQ音乐',
|
|
48
|
+
netease: '网易云',
|
|
49
|
+
kuwo: '酷我',
|
|
50
|
+
kugou: '酷狗',
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export function resolveSourceAlias(input: string): MusicSource | undefined {
|
|
54
|
+
return SOURCE_ALIASES[input.trim()];
|
|
20
55
|
}
|
|
21
56
|
|
|
22
|
-
/** 格式化时长 */
|
|
23
57
|
export function formatDuration(seconds?: number): string {
|
|
24
|
-
if (!seconds) return '未知'
|
|
25
|
-
|
|
26
|
-
const
|
|
27
|
-
|
|
28
|
-
return `${minutes}:${secs.toString().padStart(2, '0')}`
|
|
58
|
+
if (!seconds) return '未知';
|
|
59
|
+
const minutes = Math.floor(seconds / 60);
|
|
60
|
+
const secs = seconds % 60;
|
|
61
|
+
return `${minutes}:${secs.toString().padStart(2, '0')}`;
|
|
29
62
|
}
|
|
30
63
|
|
|
31
|
-
/** 格式化音乐信息 */
|
|
32
64
|
export function formatMusicInfo(music: {
|
|
33
|
-
title: string
|
|
34
|
-
artist?: string
|
|
35
|
-
album?: string
|
|
36
|
-
duration?: number
|
|
37
|
-
source: string
|
|
65
|
+
title: string;
|
|
66
|
+
artist?: string;
|
|
67
|
+
album?: string;
|
|
68
|
+
duration?: number;
|
|
69
|
+
source: string;
|
|
38
70
|
}): string {
|
|
39
|
-
const parts = [music.title]
|
|
40
|
-
|
|
41
|
-
if (music.
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
parts.push(music.album)
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
const info = parts.join(' - ')
|
|
50
|
-
const duration = formatDuration(music.duration)
|
|
51
|
-
const source = music.source.toUpperCase()
|
|
52
|
-
|
|
53
|
-
return `${info} [${duration}] [${source}]`
|
|
71
|
+
const parts = [music.title];
|
|
72
|
+
if (music.artist) parts.push(music.artist);
|
|
73
|
+
if (music.album) parts.push(music.album);
|
|
74
|
+
const info = parts.join(' - ');
|
|
75
|
+
const duration = formatDuration(music.duration);
|
|
76
|
+
const source = music.source.toUpperCase();
|
|
77
|
+
return `${info} [${duration}] [${source}]`;
|
|
54
78
|
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createGenerationStore,
|
|
3
|
+
type Dispose,
|
|
4
|
+
type GenerationStoreContext,
|
|
5
|
+
} from 'zhin.js';
|
|
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
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
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';
|
|
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';
|
|
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×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
|
+
}
|