@zhin.js/plugin-music 7.0.8 → 7.0.10

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
package/src/session.ts ADDED
@@ -0,0 +1,60 @@
1
+ import type { MusicInfo, MusicSource } from './types.js';
2
+
3
+ export interface PendingSearch {
4
+ readonly results: readonly MusicInfo[];
5
+ readonly source: MusicSource;
6
+ readonly keyword: string;
7
+ readonly timestamp: number;
8
+ }
9
+
10
+ const SEARCH_TIMEOUT_MS = 3 * 60 * 1000;
11
+
12
+ const pendingSessions = new Map<string, PendingSearch>();
13
+
14
+ export function sessionKey(
15
+ endpointId: string,
16
+ conversationId: string,
17
+ senderId: string,
18
+ ): string {
19
+ return `${endpointId}:${conversationId}:${senderId}`;
20
+ }
21
+
22
+ export function resolveMessageIds(input: {
23
+ metadata?: Record<string, unknown>;
24
+ conversation?: { id?: unknown; endpoint?: { id?: unknown } };
25
+ sender?: { id?: unknown };
26
+ }): { endpointId: string; conversationId: string; senderId: string } | null {
27
+ const meta = input.metadata ?? {};
28
+ const endpointId = String(meta.endpointId ?? input.conversation?.endpoint?.id ?? '');
29
+ const conversationId = String(input.conversation?.id ?? '');
30
+ const senderId = String(input.sender?.id ?? '');
31
+ if (!endpointId || !conversationId || !senderId) return null;
32
+ return { endpointId, conversationId, senderId };
33
+ }
34
+
35
+ export function setPending(key: string, search: PendingSearch): void {
36
+ pendingSessions.set(key, search);
37
+ }
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;
45
+ }
46
+ return session;
47
+ }
48
+
49
+ export function clearPending(key: string): void {
50
+ pendingSessions.delete(key);
51
+ }
52
+
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);
58
+ }
59
+ }
60
+ }
@@ -1,13 +1,17 @@
1
- // plugins/utils/music/src/sources/index.ts
2
- import { QQMusicService } from './qq.js'
3
- import { NeteaseMusicService } from './netease.js'
4
- import type { MusicSource, MusicSearchService } from '../types.js'
1
+ import { QQMusicService } from './qq.js';
2
+ import { NeteaseMusicService } from './netease.js';
3
+ import { KuwoMusicService } from './kuwo.js';
4
+ import { KugouMusicService } from './kugou.js';
5
+ import type { MusicSource, MusicSearchService } from '../types.js';
5
6
 
6
- /** 音乐源服务映射 */
7
7
  export const musicServices: Record<MusicSource, MusicSearchService> = {
8
8
  qq: new QQMusicService(),
9
9
  netease: new NeteaseMusicService(),
10
- }
10
+ kuwo: new KuwoMusicService(),
11
+ kugou: new KugouMusicService(),
12
+ };
11
13
 
12
- export * from './qq.js'
13
- export * from './netease.js'
14
+ export * from './qq.js';
15
+ export * from './netease.js';
16
+ export * from './kuwo.js';
17
+ export * from './kugou.js';
@@ -0,0 +1,78 @@
1
+ import type { MusicSearchService, MusicDetail, MusicInfo } from '../types.js';
2
+
3
+ interface KugouSearchItem {
4
+ hash: string;
5
+ songname: string;
6
+ singername: string;
7
+ album_name?: string;
8
+ duration: number;
9
+ album_id?: string;
10
+ }
11
+
12
+ export class KugouMusicService implements MusicSearchService {
13
+ async search(keyword: string, limit = 10): Promise<MusicInfo[]> {
14
+ try {
15
+ const url = new URL('https://mobilecdn.kugou.com/api/v3/search/song');
16
+ url.searchParams.set('format', 'json');
17
+ url.searchParams.set('keyword', keyword);
18
+ url.searchParams.set('page', '1');
19
+ url.searchParams.set('pagesize', String(limit));
20
+ url.searchParams.set('showtype', '1');
21
+
22
+ const response = await fetch(url, { method: 'GET' });
23
+ const data = (await response.json()) as {
24
+ data?: { info?: KugouSearchItem[] };
25
+ };
26
+
27
+ const items = data.data?.info ?? [];
28
+ return items.slice(0, limit).map((item) => ({
29
+ id: item.hash,
30
+ source: 'kugou' as const,
31
+ title: item.songname?.replace(/<[^>]+>/g, '') ?? '',
32
+ artist: item.singername,
33
+ album: item.album_name,
34
+ duration: item.duration,
35
+ url: `https://www.kugou.com/song/#hash=${item.hash}`,
36
+ }));
37
+ } catch {
38
+ return [];
39
+ }
40
+ }
41
+
42
+ async getDetail(id: string): Promise<MusicDetail> {
43
+ const url = new URL('https://wwwapi.kugou.com/yy/index.php');
44
+ url.searchParams.set('r', 'play/getdata');
45
+ url.searchParams.set('hash', id);
46
+ url.searchParams.set('mid', 'music');
47
+ url.searchParams.set('platid', '4');
48
+
49
+ const response = await fetch(url, { method: 'GET' });
50
+ const result = (await response.json()) as {
51
+ data?: {
52
+ song_name?: string;
53
+ author_name?: string;
54
+ album_name?: string;
55
+ img?: string;
56
+ play_url?: string;
57
+ timelength?: number;
58
+ };
59
+ };
60
+ const info = result.data;
61
+ if (!info) throw new Error('Music not found');
62
+
63
+ const audio = info.play_url ?? '';
64
+ if (!audio) throw new Error('Audio URL not found');
65
+
66
+ return {
67
+ id,
68
+ source: 'kugou',
69
+ title: info.song_name ?? '',
70
+ artist: info.author_name,
71
+ album: info.album_name,
72
+ url: `https://www.kugou.com/song/#hash=${id}`,
73
+ image: info.img ?? '',
74
+ audio,
75
+ duration: info.timelength ? Math.floor(info.timelength / 1000) : undefined,
76
+ };
77
+ }
78
+ }
@@ -0,0 +1,94 @@
1
+ import type { MusicSearchService, MusicDetail, MusicInfo } from '../types.js';
2
+
3
+ interface KuwoSearchItem {
4
+ rid: number;
5
+ name: string;
6
+ artist: string;
7
+ album: string;
8
+ duration: number;
9
+ pic?: string;
10
+ albumpic?: string;
11
+ }
12
+
13
+ export class KuwoMusicService implements MusicSearchService {
14
+ async search(keyword: string, limit = 10): Promise<MusicInfo[]> {
15
+ try {
16
+ const url = new URL('https://search.kuwo.cn/r.s');
17
+ url.searchParams.set('client', 'kt');
18
+ url.searchParams.set('all', keyword);
19
+ url.searchParams.set('pn', '0');
20
+ url.searchParams.set('rn', String(limit));
21
+ url.searchParams.set('ft', 'music');
22
+ url.searchParams.set('encoding', 'utf8');
23
+ url.searchParams.set('rformat', 'json');
24
+ url.searchParams.set('mobi', '1');
25
+ url.searchParams.set('vipver', '1');
26
+
27
+ const response = await fetch(url, { method: 'GET' });
28
+ const text = await response.text();
29
+ const data = JSON.parse(text.replace(/'/g, '"')) as {
30
+ abslist?: KuwoSearchItem[];
31
+ };
32
+
33
+ const items = data.abslist ?? [];
34
+ return items.slice(0, limit).map((item) => ({
35
+ id: String(item.rid),
36
+ source: 'kuwo' as const,
37
+ title: item.name?.replace(/&nbsp;/g, ' ') ?? '',
38
+ artist: item.artist?.replace(/&nbsp;/g, ' '),
39
+ album: item.album?.replace(/&nbsp;/g, ' '),
40
+ duration: item.duration,
41
+ url: `https://www.kuwo.cn/play_detail/${item.rid}`,
42
+ }));
43
+ } catch {
44
+ return [];
45
+ }
46
+ }
47
+
48
+ async getDetail(id: string): Promise<MusicDetail> {
49
+ const url = new URL('https://www.kuwo.cn/api/www/music/musicInfo');
50
+ url.searchParams.set('mid', id);
51
+ url.searchParams.set('httpsStatus', '1');
52
+
53
+ const response = await fetch(url, {
54
+ method: 'GET',
55
+ headers: { Referer: 'https://www.kuwo.cn/', csrf: id },
56
+ });
57
+ const result = (await response.json()) as {
58
+ data?: {
59
+ name?: string;
60
+ artist?: string;
61
+ album?: string;
62
+ pic?: string;
63
+ albumpic?: string;
64
+ duration?: number;
65
+ };
66
+ };
67
+ const info = result.data;
68
+ if (!info) throw new Error('Music not found');
69
+
70
+ return {
71
+ id,
72
+ source: 'kuwo',
73
+ title: info.name ?? '',
74
+ artist: info.artist,
75
+ album: info.album,
76
+ url: `https://www.kuwo.cn/play_detail/${id}`,
77
+ image: info.pic ?? info.albumpic ?? '',
78
+ audio: await this.getAudioUrl(id),
79
+ duration: info.duration,
80
+ };
81
+ }
82
+
83
+ async getAudioUrl(id: string): Promise<string> {
84
+ const url = `https://antiserver.kuwo.cn/anti.s?type=convert_url3&rid=${id}&format=mp3`;
85
+ const response = await fetch(url, {
86
+ method: 'GET',
87
+ redirect: 'follow',
88
+ });
89
+ const text = await response.text();
90
+ const match = /http[^\s]+/.exec(text);
91
+ if (match) return match[0];
92
+ throw new Error('Audio URL not found');
93
+ }
94
+ }
@@ -1,5 +1,6 @@
1
1
  // plugins/utils/music/src/sources/netease.ts
2
- import type { MusicSearchService, MusicDetail,MusicInfo, Music163 } from '../types.js'
2
+ import type { MusicSearchService, MusicDetail, MusicInfo, Music163 } from '../types.js';
3
+ import { getCredential } from '../credential-store.js';
3
4
 
4
5
  /** 网易云音乐搜索服务 */
5
6
  export class NeteaseMusicService implements MusicSearchService {
@@ -70,16 +71,32 @@ export class NeteaseMusicService implements MusicSearchService {
70
71
  * @param metingAPI Meting API 地址(可选)
71
72
  * @returns 音频直链 URL
72
73
  */
73
- async getAudioUrl(id: string, metingAPI?: string): Promise<string> {
74
- // 默认使用 Meting API
75
- const apiUrl = metingAPI || 'https://api.injahow.cn/meting/'
76
- const url = `${apiUrl}?type=url&id=${id}`
77
-
78
- const response = await fetch(url, { method: 'GET' })
79
- const data = await response.json() as { url?: string, data?: { url?: string } }
80
- if (!data.url && !data.data?.url) throw new Error('Audio URL not found')
81
- const audioUrl = data.url ?? data.data?.url
82
- if (!audioUrl) throw new Error('Audio URL not found')
83
- return audioUrl
74
+ async getAudioUrl(id: string): Promise<string> {
75
+ const cookie = await getCredential('netease', 'cookie');
76
+ if (cookie) {
77
+ try {
78
+ const response = await fetch(
79
+ `https://music.163.com/api/song/enhance/player/url?ids=[${id}]&br=320000`,
80
+ {
81
+ method: 'GET',
82
+ headers: { Cookie: cookie },
83
+ },
84
+ );
85
+ const result = await response.json() as {
86
+ data?: Array<{ url?: string | null }>;
87
+ };
88
+ const audioUrl = result.data?.[0]?.url;
89
+ if (audioUrl) return audioUrl;
90
+ } catch {
91
+ // cookie-based fetch failed, fall through to public API
92
+ }
93
+ }
94
+ const apiUrl = 'https://api.injahow.cn/meting/';
95
+ const url = `${apiUrl}?type=url&id=${id}`;
96
+ const response = await fetch(url, { method: 'GET' });
97
+ const data = await response.json() as { url?: string; data?: { url?: string } };
98
+ const audioUrl = data.url ?? data.data?.url;
99
+ if (!audioUrl) throw new Error('Audio URL not found');
100
+ return audioUrl;
84
101
  }
85
102
  }
package/src/sources/qq.ts CHANGED
@@ -1,147 +1,180 @@
1
- // plugins/utils/music/src/sources/qq.ts
2
- import type { MusicSearchService, MusicDetail,MusicInfo, MusicQQ } from '../types.js'
1
+ import * as crypto from 'node:crypto';
2
+ import type { MusicSearchService, MusicDetail, MusicInfo } from '../types.js';
3
+ import { getCredential } from '../credential-store.js';
4
+
5
+ const md5 = (text: string) => crypto.createHash('md5').update(text).digest('hex');
6
+
7
+ const MUSICU_API = 'https://u.y.qq.com/cgi-bin/musicu.fcg';
8
+
9
+ function getHeaders(cookie?: string | null) {
10
+ return {
11
+ Referer: 'https://y.qq.com',
12
+ Cookie: cookie ?? '',
13
+ 'Content-Type': 'application/json; charset=UTF-8',
14
+ };
15
+ }
3
16
 
4
- /** QQ 音乐搜索服务 */
5
17
  export class QQMusicService implements MusicSearchService {
6
18
  async search(keyword: string, limit = 10): Promise<MusicInfo[]> {
7
19
  try {
8
- const url = new URL('https://c.y.qq.com/splcloud/fcgi-bin/smartbox_new.fcg')
9
- url.searchParams.set('key', keyword)
10
- url.searchParams.set('format', 'json')
11
-
12
- const response = await fetch(url, { method: 'GET' })
13
- const data = await response.json() as { data: { song: { itemlist: MusicQQ[] } } }
14
-
15
- const items = data.data?.song?.itemlist || []
16
- return items.slice(0, limit).map(music => ({
17
- id: music.id,
18
- source: 'qq' as const,
19
- title: music.name,
20
- url: `https://y.qq.com/n/yqq/song/${music.mid}.html`
21
- }))
22
- } catch (error) {
23
- console.error('QQ Music search failed:', error)
24
- return []
25
- }
26
- }
20
+ const cookie = await getCredential('qq', 'cookie');
21
+ const response = await fetch(MUSICU_API, {
22
+ method: 'POST',
23
+ headers: getHeaders(cookie),
24
+ body: JSON.stringify({
25
+ comm: { uin: '0', authst: '', ct: 29 },
26
+ search: {
27
+ method: 'DoSearchForQQMusicMobile',
28
+ module: 'music.search.SearchCgiService',
29
+ param: {
30
+ query: keyword,
31
+ grp: 1,
32
+ num_per_page: limit,
33
+ page_num: 1,
34
+ search_type: 0,
35
+ searchid: String(Date.now()),
36
+ },
37
+ },
38
+ }),
39
+ });
27
40
 
28
- async getCover(id: string): Promise<string | null> {
29
- try {
30
- const url = new URL('https://u.y.qq.com/cgi-bin/musicu.fcg')
31
- url.searchParams.set('format', 'json')
32
- url.searchParams.set('inCharset', 'utf8')
33
- url.searchParams.set('outCharset', 'utf-8')
34
- url.searchParams.set('notice', '0')
35
- url.searchParams.set('platform', 'yqq.json')
36
- url.searchParams.set('needNewCode', '0')
37
- url.searchParams.set('data', JSON.stringify({
38
- comm: { ct: 24, cv: 0 },
39
- songinfo: {
40
- method: 'get_song_detail_yqq',
41
- param: { song_type: 0, song_mid: '', song_id: parseInt(id) },
42
- module: 'music.pf_song_detail_svr'
43
- }
44
- }))
45
-
46
- const response = await fetch(url, { method: 'GET' })
47
- const result = await response.json()
48
-
49
- const albumMid = result?.songinfo?.data?.track_info?.album?.mid
50
- if (!albumMid) return null
51
-
52
- return `https://y.gtimg.cn/music/photo_new/T002R300x300M000${albumMid}.jpg`
41
+ const data = (await response.json()) as { code?: number; search?: { data?: { body?: { song?: { list?: any[] }; item_song?: any[] } } } };
42
+ if (data.code !== undefined && +data.code !== 0) return [];
43
+
44
+ const body = data.search?.data?.body ?? {};
45
+ const list = (body.song?.list ?? body.item_song ?? []) as any[];
46
+
47
+ return list.slice(0, limit).map((e) => {
48
+ const mid = e?.mid ?? '';
49
+ const albumMid = e?.album?.pmid ?? e?.album?.mid ?? '';
50
+ const singerMid = e?.singer?.[0]?.pmid ?? e?.singer?.[0]?.mid ?? '';
51
+ const cover = albumMid
52
+ ? `https://y.gtimg.cn/music/photo_new/T002R300x300M000${albumMid}.jpg`
53
+ : singerMid
54
+ ? `https://y.qq.com/music/photo_new/T001R300x300M000${singerMid}.jpg`
55
+ : '';
56
+
57
+ return {
58
+ id: String(e.id),
59
+ source: 'qq' as const,
60
+ title: e.name,
61
+ artist: (e.singer ?? []).map((s: any) => s.name).join('/'),
62
+ album: e.album?.name,
63
+ url: `https://y.qq.com/n/ryqq/songDetail/${mid}`,
64
+ image: cover,
65
+ duration: e.interval,
66
+ _mid: mid,
67
+ _mediaMid: e.file?.media_mid,
68
+ };
69
+ });
53
70
  } catch (error) {
54
- console.error('QQ Music get cover failed:', error)
55
- return null
71
+ console.error('QQ Music search failed:', error);
72
+ return [];
56
73
  }
57
74
  }
58
75
 
59
76
  async getDetail(id: string): Promise<MusicDetail> {
60
- const url = new URL('https://u.y.qq.com/cgi-bin/musicu.fcg')
61
- url.searchParams.set('format', 'json')
62
- url.searchParams.set('data', JSON.stringify({
63
- comm: { ct: 24, cv: 0 },
64
- songinfo: {
65
- method: 'get_song_detail_yqq',
66
- param: { song_type: 0, song_mid: '', song_id: parseInt(id) },
67
- module: 'music.pf_song_detail_svr'
68
- }
69
- }))
70
-
71
- const response = await fetch(url, { method: 'GET' })
72
- const result = await response.json()
73
-
74
- const trackInfo = result?.songinfo?.data?.track_info
75
- if (!trackInfo) throw new Error('Music not found')
76
-
77
- const albumMid = trackInfo.album?.mid
78
- const image = albumMid
79
- ? `https://y.gtimg.cn/music/photo_new/T002R300x300M000${albumMid}.jpg`
80
- : undefined
81
-
82
- return {
83
- id,
84
- source: 'qq',
85
- title: trackInfo.name,
86
- url: `https://y.qq.com/n/yqq/song/${trackInfo.mid}.html`,
87
- audio: await this.getAudioUrl(id),
88
- image: image || '',
89
- duration: trackInfo.interval,
90
- }
77
+ const url = new URL(MUSICU_API);
78
+ url.searchParams.set('format', 'json');
79
+ url.searchParams.set('data', JSON.stringify({
80
+ comm: { ct: 24, cv: 0 },
81
+ songinfo: {
82
+ method: 'get_song_detail_yqq',
83
+ param: { song_type: 0, song_mid: '', song_id: parseInt(id) },
84
+ module: 'music.pf_song_detail_svr',
85
+ },
86
+ }));
87
+
88
+ const response = await fetch(url, { method: 'GET' });
89
+ const result = await response.json() as any;
90
+
91
+ const trackInfo = result?.songinfo?.data?.track_info;
92
+ if (!trackInfo) throw new Error('Music not found');
93
+
94
+ const albumMid = trackInfo.album?.mid;
95
+ const image = albumMid
96
+ ? `https://y.gtimg.cn/music/photo_new/T002R300x300M000${albumMid}.jpg`
97
+ : '';
98
+
99
+ return {
100
+ id,
101
+ source: 'qq',
102
+ title: trackInfo.name,
103
+ url: `https://y.qq.com/n/ryqq/songDetail/${trackInfo.mid}`,
104
+ audio: await this.getAudioUrl(id, trackInfo.mid, trackInfo.file?.media_mid),
105
+ image,
106
+ duration: trackInfo.interval,
107
+ };
91
108
  }
92
109
 
93
- /**
94
- * 获取音频直链(需要第三方 API)
95
- * @param id 音乐 ID
96
- * @param metingAPI Meting API 地址(可选)
97
- * @returns 音频直链 URL
98
- */
99
- async getAudioUrl(id: string, metingAPI?: string): Promise<string> {
100
- // QQ 音乐需要使用第三方 API
101
- const apiUrl = metingAPI || 'https://api.injahow.cn/meting/'
102
- const url = `${apiUrl}?type=url&id=${id}&source=qq`
103
-
104
- const response = await fetch(url, { method: 'GET' })
105
- const data = await response.json() as { url?: string, data?: { url?: string } }
106
- if (!data.url && !data.data?.url) {
107
- throw new Error('Audio URL not found')
110
+ async getAudioUrl(id: string, mid?: string, mediaMid?: string): Promise<string> {
111
+ const cookie = await getCredential('qq', 'cookie');
112
+ if (cookie) {
113
+ try {
114
+ const uin = /uin=o?(\d+)/.exec(cookie)?.[1] ?? '0';
115
+ const songMid = mid ?? id;
116
+ const response = await fetch(MUSICU_API, {
117
+ method: 'POST',
118
+ headers: getHeaders(cookie),
119
+ body: JSON.stringify({
120
+ comm: {
121
+ ct: 19,
122
+ cv: '1891',
123
+ uin: '0',
124
+ guid: md5(`${uin}music`),
125
+ tmeAppID: 'qqmusic',
126
+ tmeLoginType: 2,
127
+ },
128
+ vkey: {
129
+ module: 'vkey.GetVkeyServer',
130
+ method: 'CgiGetVkey',
131
+ param: {
132
+ guid: md5(String(Date.now())),
133
+ songmid: [songMid],
134
+ songtype: [0],
135
+ uin: '0',
136
+ ctx: 1,
137
+ filename: [`C400${id}${mediaMid ?? songMid}.m4a`],
138
+ },
139
+ },
140
+ }),
141
+ });
142
+
143
+ const result = await response.json() as any;
144
+ if (result.vkey && +result.vkey.code === 0) {
145
+ const purl = result.vkey.data?.midurlinfo?.[0]?.purl;
146
+ if (purl) return `https://ws.stream.qqmusic.qq.com/${purl}`;
147
+ }
148
+ } catch {
149
+ // fall through to public fallback
108
150
  }
109
- const audioUrl = data.url ?? data.data?.url
110
- if (!audioUrl) throw new Error('Audio URL not found')
111
- return audioUrl
151
+ }
152
+
153
+ // 无 cookie 的公开 fallback:md5 签名直链
154
+ const songMid = mid ?? id;
155
+ const code = md5(`${songMid}q;z(&l~sdf2!nK`).substring(0, 5).toUpperCase();
156
+ return `https://c6.y.qq.com/rsc/fcgi-bin/fcg_pyq_play.fcg?songmid=${songMid}&code=${code}`;
112
157
  }
113
158
 
114
- /**
115
- * 获取歌词
116
- * @param id 音乐 ID
117
- * @returns 歌词文本
118
- */
119
159
  async getLyric(id: string): Promise<string | null> {
120
160
  try {
121
- const url = new URL('https://c.y.qq.com/lyric/fcgi-bin/fcg_query_lyric_new.fcg')
122
- url.searchParams.set('songmid', id)
123
- url.searchParams.set('g_tk', '5381')
124
- url.searchParams.set('format', 'json')
125
- url.searchParams.set('inCharset', 'utf8')
126
- url.searchParams.set('outCharset', 'utf-8')
127
- url.searchParams.set('nobase64', '1')
128
-
129
- const response = await fetch(url, {
161
+ const url = new URL('https://c.y.qq.com/lyric/fcgi-bin/fcg_query_lyric_new.fcg');
162
+ url.searchParams.set('songmid', id);
163
+ url.searchParams.set('g_tk', '5381');
164
+ url.searchParams.set('format', 'json');
165
+ url.searchParams.set('inCharset', 'utf8');
166
+ url.searchParams.set('outCharset', 'utf-8');
167
+ url.searchParams.set('nobase64', '1');
168
+
169
+ const response = await fetch(url, {
130
170
  method: 'GET',
131
- headers: {
132
- 'Referer': 'https://y.qq.com'
133
- }
134
- })
135
- const data = await response.json() as { lyric?: string }
136
-
137
- if (!data.lyric) return null
138
-
139
- // 请求已带 nobase64=1,返回的是明文歌词,直接返回;
140
- // 再按 base64 解码会得到乱码(Buffer.from(x,'base64') 对明文也不会抛错)
141
- return data.lyric
171
+ headers: { Referer: 'https://y.qq.com' },
172
+ });
173
+ const data = (await response.json()) as { lyric?: string };
174
+ return data.lyric ?? null;
142
175
  } catch (error) {
143
- console.error('QQ Music get lyric failed:', error)
144
- return null
176
+ console.error('QQ Music get lyric failed:', error);
177
+ return null;
145
178
  }
146
179
  }
147
180
  }