Repository navigation
Expand file tree
/
Copy pathmulti-source.js
More file actions
1120 lines (1004 loc) · 40.1 KB
/
Copy pathmulti-source.js
File metadata and controls
1120 lines (1004 loc) · 40.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
/**
* 多平台歌单导入插件(QQ音乐 + 网易云 + Audiomack)
* MusicFree 插件协议:CommonJS 模块
*
* 设计要点:
* - 一个插件只有一个 platform,宿主 resetMediaItem 会把导入歌曲的 platform 强制改成它。
* - 所以平台区分不能靠 platform,必须自带 source 字段("qq" | "netease" | "audiomack")。
* - 歌曲 id 统一构造成 `${source}:${原生id}`,保证单主键下全局唯一、且能反解出源。
*/
const axios = require("axios");
const CryptoJS = require("crypto-js");
/**
* 宿主在 plugin.ts 里设了 axios.defaults.timeout = 2000,对本插件这种
* 「一次操作要打好几个平台的请求」来说太紧(尤其 audiomack 在国内访问常要 1-3s)。
* 这里本地包一层,只给本插件的请求设更长的超时,**不去改宿主的全局默认值**。
*/
const REQ_TIMEOUT = 12000;
// 搜索是"谁慢谁拖累整轮",单独给更短的超时:最坏情况只等这么久
const SEARCH_TIMEOUT = 7000;
const http = {
get: (url, config) => axios({ method: "get", url, timeout: REQ_TIMEOUT, ...config }),
post: (url, data, config) =>
axios({ method: "post", url, data, timeout: REQ_TIMEOUT, ...config }),
search: (url, config) =>
axios({ method: "get", url, timeout: SEARCH_TIMEOUT, ...config }),
searchPost: (url, data, config) =>
axios({ method: "post", url, data, timeout: SEARCH_TIMEOUT, ...config }),
};
const QQ_UA =
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36";
const NE_UA = QQ_UA;
const AM_UA = QQ_UA;
const QQ_HEADERS = { "User-Agent": QQ_UA, Referer: "https://y.qq.com/" };
const NE_HEADERS = { "User-Agent": NE_UA, Referer: "https://music.163.com/" };
const AM_HEADERS = {
"User-Agent": AM_UA,
origin: "https://audiomack.com",
referer: "https://audiomack.com/",
};
const QQ_PAGE_SIZE = 1000;
// 换源命中时,把实际播放的来源挂在结果上(调试/展示用,宿主会忽略未知键)
const _FALLBACK_FROM = "_fallbackFrom";
// netease 的 c 参数有长度上限:300 个 id(编码后约 8K 字符)还行,
// 424 个(约 11.4K)会返回 code:400 "请求解析失败!",所以留足余量用 100。
const NE_BATCH = 100;
/* ------------------------------ 通用工具 ------------------------------ */
/**
* 取宿主注入的 env。
* 宿主把插件体包成 function(require, require, module, exports, console, env, URL, process){...},
* 所以 env 是**包装函数的参数**、不是 global.env(官方插件全都直接用裸 env)。
* typeof 判断可避免 env 未声明时抛 ReferenceError。
*/
function getEnv() {
try {
if (typeof env !== "undefined" && env) return env;
} catch (e) {}
try {
if (typeof global !== "undefined" && global.env) return global.env;
} catch (e) {}
return null;
}
function getUserVariables() {
try {
const e = getEnv();
return (e && e.getUserVariables && e.getUserVariables()) || {};
} catch (e) {
return {};
}
}
function joinArtists(list) {
if (!list) return "";
return list
.map(x => (typeof x === "string" ? x : x && (x.name || x.nickname)))
.filter(Boolean)
.join(" / ");
}
/** 提取歌单 id:支持纯 id / URL / 整段分享文本 */
function pickId(urlLike, patterns) {
const text = String(urlLike || "");
for (const re of patterns) {
const m = text.match(re);
if (m && m[1]) return m[1];
}
return null;
}
const QQ_ID_PATTERNS = [
/[?&](?:disstid|id)=(\d{5,})/i,
/\/playlist\/(\d{5,})/i,
/\/taoge\/[^?]*\?[^#]*id=(\d{5,})/i,
/^\s*(\d{5,})\s*$/,
];
const NE_ID_PATTERNS = [
/[?&#](?:id|playlistId)=(\d{5,})/i,
/\/playlist\/(\d{5,})/i,
/^\s*(\d{5,})\s*$/,
];
/** 短链一般是 302/JS 跳转,跟一次再用正则兜底 */
async function resolveShortLink(url, headers, extraPatterns) {
try {
const res = await http.get(url, {
headers,
maxRedirects: 5,
validateStatus: () => true,
});
const finalUrl = (res.request && res.request.res && res.request.res.responseUrl) || "";
let id = pickId(finalUrl, extraPatterns);
if (id) return id;
const body = typeof res.data === "string" ? res.data : JSON.stringify(res.data || "");
id = pickId(body, extraPatterns);
return id || null;
} catch (e) {
return null;
}
}
/* ------------------------------ QQ 音乐 ------------------------------ */
function qqAlbumCover(album) {
const mid = (album && (album.pmid || album.mid)) || "";
return mid ? `https://y.qq.com/music/photo_new/T002R300x300M000${mid}.jpg` : "";
}
function formatQQSong(s) {
return {
id: `qq:${s.mid}`,
source: "qq",
songmid: s.mid,
// 播放要用的 media_mid(文件名里的 mid,和 songmid 不一定相同)
mediaMid: (s.file && s.file.media_mid) || s.mid,
title: s.name || s.title || "",
artist: joinArtists(s.singer),
album: (s.album && s.album.name) || "",
duration: Number(s.interval) || 0,
artwork: qqAlbumCover(s.album),
// 付费/试听标记,宿主入库后可以用来提示
pay: (s.pay && s.pay.pay_play) || 0,
};
}
/** 现代接口 musicu.fcg CgiGetDiss(免登录可取公开歌单元数据) */
async function qqFetchPage(disstid, begin, cookie) {
const body = {
comm: { ct: 20, cv: 1846, uin: "0", format: "json" },
req: {
module: "music.srfDissInfo.DissInfo",
method: "CgiGetDiss",
param: {
disstid: Number(disstid),
dirid: 0,
tag: 1,
song_begin: begin,
song_num: QQ_PAGE_SIZE,
userinfo: 1,
orderlist: 1,
onlysonglist: 0,
pic: 1,
pic_size: 800,
},
},
};
const res = await http.post("https://u.y.qq.com/cgi-bin/musicu.fcg", body, {
headers: {
...QQ_HEADERS,
"Content-Type": "application/json",
...(cookie ? { Cookie: cookie } : {}),
},
});
const data = (res.data && res.data.req && res.data.req.data) || {};
return {
dirinfo: data.dirinfo || {},
songlist: data.songlist || [],
total: Number(data.total_song_num) || 0,
};
}
async function importQQ(urlLike) {
const { qq_cookie } = getUserVariables();
let disstid = pickId(urlLike, QQ_ID_PATTERNS);
if (!disstid && /^https?:\/\//i.test(String(urlLike))) {
disstid = await resolveShortLink(String(urlLike), QQ_HEADERS, QQ_ID_PATTERNS);
}
if (!disstid) return null;
const first = await qqFetchPage(disstid, 0, qq_cookie);
let songs = first.songlist.slice();
const total = first.total || songs.length;
while (songs.length < total && first.songlist.length > 0) {
const next = await qqFetchPage(disstid, songs.length, qq_cookie);
if (!next.songlist.length) break;
songs = songs.concat(next.songlist);
}
if (!songs.length) return null;
return songs.map(formatQQSong);
}
/* ------------------------------ 网易云音乐 ------------------------------ */
function neSongToItem(t) {
// v6/playlist/detail 的 tracks 可能是 legacy(artists/album/duration) 也可能是新格式(ar/al/dt)
const artists = t.ar || t.artists || [];
const album = t.al || t.album || {};
const durationMs = t.dt != null ? t.dt : t.duration;
return {
id: `ne:${t.id}`,
source: "netease",
nid: t.id,
title: t.name || "",
artist: joinArtists(artists),
album: album.name || "",
duration: Math.round((Number(durationMs) || 0) / 1000),
artwork: album.picUrl || "",
// fee: 0 免费 / 1 VIP / 4 购买专辑 / 8 低音质免费
pay: t.fee,
};
}
/** 单批拉取;失败(code!==200)就折半重试,避免长参数导致整批静默丢失 */
async function neFetchChunk(ids, cookie, depth = 0) {
if (!ids.length) return [];
try {
const res = await http.get("https://music.163.com/api/v3/song/detail", {
headers: { ...NE_HEADERS, ...(cookie ? { Cookie: cookie } : {}) },
params: { c: JSON.stringify(ids.map(id => ({ id }))) },
validateStatus: () => true,
});
const body = res.data || {};
const songs = Array.isArray(body.songs) ? body.songs : null;
// code 正常即可信:songs 比 ids 少是正常的(歌曲被删/下架会被过滤)
if (res.status === 200 && body.code === 200 && songs) return songs;
console.log(`[NE] 批量详情 code=${body.code} status=${res.status} ids=${ids.length},折半重试`);
} catch (e) {
console.log("[NE] 批量详情异常:", e.message);
}
if (ids.length === 1 || depth >= 6) return [];
const mid = Math.ceil(ids.length / 2);
return [
...(await neFetchChunk(ids.slice(0, mid), cookie, depth + 1)),
...(await neFetchChunk(ids.slice(mid), cookie, depth + 1)),
];
}
async function neBatchDetail(ids, cookie) {
const out = [];
for (let i = 0; i < ids.length; i += NE_BATCH) {
out.push(...(await neFetchChunk(ids.slice(i, i + NE_BATCH), cookie)));
}
return out;
}
async function importNetease(urlLike) {
const { ne_cookie } = getUserVariables();
let pid = pickId(urlLike, NE_ID_PATTERNS);
if (!pid && /^https?:\/\//i.test(String(urlLike))) {
pid = await resolveShortLink(String(urlLike), NE_HEADERS, NE_ID_PATTERNS);
}
if (!pid) return null;
const res = await http.post(
"https://music.163.com/api/v6/playlist/detail",
`id=${pid}&n=1000&s=0`,
{
headers: {
...NE_HEADERS,
"Content-Type": "application/x-www-form-urlencoded",
...(ne_cookie ? { Cookie: ne_cookie } : {}),
},
},
);
const pl = (res.data && res.data.playlist) || {};
const tracks = pl.tracks || [];
const trackIds = (pl.trackIds || []).map(x => x.id);
// 「我喜欢的音乐」(specialType=5) 等大歌单,tracks 只是前几首的预览,
// 真实列表要以 trackIds 为准,缺的用批量详情补。
const byId = new Map();
tracks.forEach(t => byId.set(t.id, t));
if (trackIds.length > tracks.length) {
const missing = trackIds.filter(id => !byId.has(id));
if (missing.length) {
const extra = await neBatchDetail(missing, ne_cookie);
extra.forEach(s => byId.set(s.id, s));
}
}
// 按 trackIds 的顺序输出(歌单顺序),拿不到详情的跳过
const order = trackIds.length ? trackIds : tracks.map(t => t.id);
const songs = order.map(id => byId.get(id)).filter(Boolean);
if (!songs.length) return null;
return songs.map(neSongToItem);
}
/* ------------------------------ Audiomack ------------------------------ */
/**
* Audiomack 的 API 需要 OAuth1(HMAC-SHA1) 签名,consumer key/secret 是从其前端
* bundle 里提取的固定值(官方 MusicFree 插件同样用法)。
* 注意:官方插件的 getMusicSheetInfo 靠爬 __NEXT_DATA__/_next/data 已经失效
* (站点迁到 Next.js App Router 了),这里改成从播放页 RSC 流里提 id 再走 API。
*/
const AM_CONSUMER_KEY = "audiomack-js";
const AM_CONSUMER_SECRET = "f3ac5b086f3eab260520d8e3049561e6";
function amNonce(e = 10) {
const n = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
let r = "";
for (let i = 0; i < e; i++) r += n.charAt(Math.floor(Math.random() * n.length));
return r;
}
/** 与 OAuth1 规范一致的百分号编码(不依赖全局 escape) */
function amEncode(v) {
if (v === undefined || v === null) return "";
return encodeURIComponent(String(v))
.replace(/[!'()]/g, c => "%" + c.charCodeAt(0).toString(16).toUpperCase())
.replace(/\*/g, "%2A");
}
/** 归一化参数:key 编码后排序,value 数组内部排序,逐个展开成 k=v 再用 & 连接 */
function amNormalizedParams(params) {
const keys = Object.keys(params).map(amEncode).sort();
const pairs = [];
for (const k of keys) {
const values = params[decodeURIComponent(k)];
(Array.isArray(values) ? [...values].sort() : [values]).forEach(v =>
pairs.push(`${k}=${amEncode(v)}`)
);
}
return pairs.join("&");
}
function amSign(method, urlPath, params, secret = AM_CONSUMER_SECRET) {
// 签名原文里的各段本身要被编码,故先还原再交给 amEncode
const normd = amNormalizedParams(params);
const base =
amEncode(method.toUpperCase()) +
"&" +
amEncode("https://api.audiomack.com/v1" + urlPath.split("?")[0]) +
"&" +
amEncode(normd);
return CryptoJS.HmacSHA1(base, secret + "&").toString(CryptoJS.enc.Base64);
}
async function amSignedGet(path, extra = {}, timeout = REQ_TIMEOUT) {
const params = {
oauth_consumer_key: AM_CONSUMER_KEY,
oauth_nonce: amNonce(32),
oauth_signature_method: "HMAC-SHA1",
oauth_timestamp: Math.round(Date.now() / 1e3),
oauth_version: "1.0",
...extra,
};
const oauth_signature = amSign("GET", path, params);
const res = await http.get("https://api.audiomack.com/v1" + path, {
headers: AM_HEADERS,
params: { ...params, oauth_signature },
timeout,
// 403(独家/地区限制) 401(签名) 都不该抛异常,交给调用方判断
validateStatus: () => true,
});
if (res.status !== 200) {
console.log(`[AM] ${path} -> HTTP ${res.status}`);
return null;
}
return res.data;
}
/** 从播放页的 RSC 流里提歌单/专辑数字 id */
function amExtractMusicId(html) {
const m = String(html).match(/\\"music\\":\{\\"id\\":(\d+)/);
return m ? m[1] : null;
}
function formatAMSong(t) {
return {
id: `am:${t.id}`,
source: "audiomack",
amid: t.id,
title: t.title || "",
artist: t.artist || "",
album: (t.album_details && t.album_details.title) || t.album || "",
duration: Number(t.duration) || 0,
artwork: t.image || t.image_base || "",
// premium_user_only 是 "yes"/"no" 字符串,统一成 0/1
pay: t.premium_user_only === "yes" || t.premium_user_only === 1 ? 1 : 0,
url_slug: t.url_slug,
};
}
async function importAudiomack(urlLike) {
const text = String(urlLike || "");
let pid = null;
// 直接给数字 id
const raw = text.match(/^\s*(\d{5,})\s*$/);
if (raw) pid = raw[1];
if (!pid && /audiomack\.com/i.test(text)) {
const url = text.match(/https?:\/\/[^\s"'<>]+/i);
if (url) {
const res = await http.get(url[0], {
headers: { "User-Agent": AM_UA },
maxRedirects: 5,
validateStatus: () => true,
});
if (typeof res.data === "string") pid = amExtractMusicId(res.data);
}
}
if (!pid) return null;
const data = await amSignedGet(`/playlist/${pid}`);
const pl = data && data.results;
if (!pl || !pl.tracks || !pl.tracks.length) return null;
return pl.tracks.map(formatAMSong);
}
/* ------------------------------ 搜索(多源聚合) ------------------------------ */
/**
* 宿主搜索流程(useSearch.ts):
* 没指定插件 -> PluginManager.getSearchablePlugins() -> forEach 并行调每个插件的 search
* 结果按 plugin.hash 分开存 searchResults[type][hash]
* UI(resultSubPanel) 按插件开 Tab,Tab 标题 = platform
* 所以:一个插件 = 一个 Tab。插件内部聚合 N 个源,结果都在同一个 Tab 里。
*/
const SEARCH_PAGE_SIZE = 20;
async function searchQQ(query, page) {
const res = await http.search("https://c.y.qq.com/soso/fcgi-bin/client_search_cp", {
headers: QQ_HEADERS,
params: { p: page, n: SEARCH_PAGE_SIZE, w: query, format: "json", t: 0, new_json: 1 },
});
const song = ((res.data || {}).data || {}).song || {};
const list = song.list || [];
const total = Number(song.totalnum) || 0;
return {
data: list.map(formatQQSong),
isEnd: !list.length || (total > 0 && total <= page * SEARCH_PAGE_SIZE),
};
}
async function searchNetease(query, page) {
const offset = (page - 1) * SEARCH_PAGE_SIZE;
const res = await http.search("https://music.163.com/api/search/get", {
headers: NE_HEADERS,
params: { s: query, type: 1, offset, limit: SEARCH_PAGE_SIZE },
});
const result = (res.data || {}).result || {};
const songs = result.songs || [];
const total = Number(result.songCount) || 0;
const data = songs.map(s => {
const album = s.album || {};
return {
id: `ne:${s.id}`,
source: "netease",
nid: s.id,
title: s.name || "",
artist: joinArtists(s.artists),
album: album.name || "",
duration: Math.round((Number(s.duration) || 0) / 1000),
// 搜索接口的 album 只有 picId,没有 picUrl,下面批量补
artwork: album.picUrl || "",
pay: s.fee,
};
});
// 搜索结果缺封面,用 v3/song/detail 批量补 picUrl(一次请求)
if (data.length && data.some(x => !x.artwork)) {
try {
const detail = await neBatchDetail(
data.map(x => x.nid),
null
);
const byId = {};
detail.forEach(s => {
const al = s.al || s.album || {};
byId[s.id] = al.picUrl || "";
});
data.forEach(x => {
if (!x.artwork && byId[x.nid]) x.artwork = byId[x.nid];
});
} catch (e) {
/* 补封面失败不影响搜索 */
}
}
return {
data,
isEnd: !songs.length || (total > 0 && offset + songs.length >= total),
};
}
async function searchAudiomack(query, page) {
const data = await amSignedGet(
"/search",
{ q: query, show: "music", sort: "popular", limit: SEARCH_PAGE_SIZE, page },
SEARCH_TIMEOUT
);
const r = (data && data.results) || {};
const list = Array.isArray(r) ? r : r.music || [];
return {
data: list.map(formatAMSong),
isEnd: list.length < SEARCH_PAGE_SIZE,
};
}
/** 轮流取各源,避免某一源把列表头部霸屏 */
function interleave(lists) {
const out = [];
const max = Math.max(0, ...lists.map(l => l.length));
for (let i = 0; i < max; i++) {
for (const l of lists) {
if (l[i]) out.push(l[i]);
}
}
return out;
}
/* ------------------------------ 跨平台兜底(换源播放) ------------------------------ */
/**
* 原生源拿不到播放地址时,去其它平台找同一首歌,用别的源的地址播。
* 匹配靠「标题 + 艺人 + 时长」三重校验,避免放错歌。
*/
const FALLBACK_ORDER = {
qq: ["netease", "audiomack"],
netease: ["qq", "audiomack"],
audiomack: ["netease", "qq"],
};
/** 归一化:去掉括号内容/标点/空格,便于比对 */
function normText(s) {
return String(s || "")
.replace(/\([^)]*\)|([^)]*)|\[[^\]]*\]|【[^】]*】/g, "")
.toLowerCase()
.replace(/[\s\-_·、,,.。!!??::;;'"“”‘’~《》<>]/g, "");
}
/** 切分艺人串,兼容 "A / B"、"A, B"、"A&B"、"A、B" */
function splitArtists(s) {
return String(s || "")
.split(/[/,&、;;]|\s+feat\.?\s+|\s+ft\.?\s+/i)
.map(normText)
.filter(Boolean);
}
/**
* 「换了演绎」的三类文字标记:候选带、目标不带 → 不是同一个录音。
* 标题归一化会剃掉括号内容,于是「如果寂寞了(伴奏)」和「如果寂寞了」变得一模一样,
* 艺人又相同时会满分命中,结果放出伴奏/消音版;cover/现场同理。
* 这三类标记是与「换了个专辑容器」区分的唯一依据(见 matchInfo)。
*/
const VERSION_BLOCK =
/伴奏|纯伴奏|消音|无人声|instrumental|off\s*vocal|offvocal|karaoke|カラオケ/i;
const COVER_BLOCK = /\bcover\b|翻唱|翻自|致敬|重唱|原唱|模仿|remix\s*版|改编/i;
const LIVE_BLOCK =
/演唱会|现场版?|音乐会|演奏会|巡回|第\s*\d+\s*期|跨年|晚会|音乐节|盛典|颁奖|歌会|我是歌手|梦想的声音|大歌神|我想和你唱|好声音|\blive\b|unplugged|acoustic/i;
function markedAs(block, target, cand) {
const hit = s => block.test(String(s || ""));
const t = hit(target.title) || hit(target.album);
const c = hit(cand.title) || hit(cand.album);
return c && !t;
}
/**
* 时长档位。同一母带(包括被复制进合集/精选集的那份)几乎逐秒相同,
* 而任何现场/重录演绎都会明显偏长偏短 —— 所以用「精确」这一档来顶替
* 过去「专辑名必须一致」的硬要求。
*/
function durationTier(target, cand) {
if (!target.duration || !cand.duration) return "unknown";
const diff = Math.abs(target.duration - cand.duration);
const base = Math.max(target.duration, cand.duration);
if (diff <= Math.max(2, base * 0.01)) return "exact";
if (diff <= Math.max(5, base * 0.05)) return "close";
return "far";
}
/**
* 同一录音判定(换源和搜索合并共用同一套规则,避免两处规则不一致)。
*
* 返回 { score, tier } 或 null(null = 不是同一个录音):
* tier = "album" 专辑名互相包含 → 身份确认
* tier = "master" 专辑名不一致(或一方缺失),但艺人重合 + 时长精确吻合
* tier = "loose" 候选**完全没有专辑字段**,只靠艺人 + 时长精确吻合顶上
* score = 标题 1 + 艺人 2 + 时长(精确 3 / 接近 2)+ 专辑确认 1,仅用于排序
*
* opts.strict = true 时不接受 loose(搜索合并用它,避免无专辑的翻录/cover
* 被"桥接"成正版的同一组;换源默认允许,否则 AM 上大量无专辑的用户上传件全废)。
*
* 硬淘汰的都是「换了演绎」的证据,而不是「换了专辑」:
* 标题不符;双方都有艺人却不重合;候选带伴奏/消音标记;候选带 cover/翻唱标记;
* 候选带现场/综艺标记;双方时长都有但差超过 5s 或 5%。
*
* 关键改动:**专辑名不一致不再单独淘汰**。海外平台常把同一录音挂在合集/精选集下——
* 实测《修炼爱情》QQ/网易云都无版权,AM 上是「8090's 经典」287s,与原专辑
* 「因你而在」287s 逐秒相同;旧规则按「专辑不同」一票否决,换源于是整个失效。
* 现在这种候选走 master 档放行;而 范特西 270s ↔ The One演唱会 273s 这类
* 被现场标记 + 3s 偏差(只到 close 档)双重拦掉。
*/
function matchInfo(target, cand, opts) {
const strict = !!(opts && opts.strict);
if (!target || !cand) return null;
const nt = normText(target.title);
const nc = normText(cand.title);
if (!nt || !nc) return null;
if (!(nt === nc || nc.includes(nt) || nt.includes(nc))) return null;
if (markedAs(VERSION_BLOCK, target, cand)) return null;
if (markedAs(COVER_BLOCK, target, cand)) return null;
if (markedAs(LIVE_BLOCK, target, cand)) return null;
const aA = splitArtists(target.artist);
const aB = splitArtists(cand.artist);
const bothHaveArtists = aA.length > 0 && aB.length > 0;
const artistOk =
bothHaveArtists && aA.some(x => aB.some(y => x.includes(y) || y.includes(x)));
// 都有艺人却对不上 → 翻唱 / 同曲不同人
if (bothHaveArtists && !artistOk) return null;
const dt = durationTier(target, cand);
// 两个已知时长差太远 → 不同录音(253s 的翻录版放不进 270s 的正版)
if (dt === "far") return null;
const alT = normText(target.album);
const alC = normText(cand.album);
const albumConfirmed = !!(alT && alC && (alT.includes(alC) || alC.includes(alT)));
let score = 1;
if (artistOk) score += 2;
if (dt === "exact") score += 3;
else if (dt === "close") score += 2;
if (albumConfirmed) score += 1;
// 通过条件:专辑确认 → 艺人吻合即可;专辑不确认 → 必须艺人吻合 + 时长精确
let tier = null;
if (albumConfirmed && (artistOk || !bothHaveArtists)) tier = "album";
else if (artistOk && dt === "exact") tier = alT && !alC ? "loose" : "master";
if (!tier) return null;
if (tier === "loose" && strict) return null;
return { score, tier };
}
/** 排序权重:身份证据越硬越靠前 */
const TIER_RANK = { album: 0, master: 1, loose: 2 };
function tierRank(info) {
const r = info && TIER_RANK[info.tier];
return r === undefined ? 9 : r;
}
/** 兼容旧调用:0 表示不是同一录音,非 0 为匹配分(>=4) */
function matchScore(target, cand) {
const info = matchInfo(target, cand);
return info ? info.score : 0;
}
/** 兜底候选是否可用 = 同一录音判定通过(宁缺毋滥);与 matchInfo 等价,保留供调试/复用 */
function fallbackTrustOk(target, cand) {
return !!matchInfo(target, cand);
}
/** 按 item.source 取播放地址(原生和兜底共用) */
async function getSourceBySource(item, quality, cookies = {}) {
if (!item) return null;
if (item.source === "qq") return await getQQMediaSource(item, quality, cookies.qq);
if (item.source === "netease") return await getNEMediaSource(item, quality, cookies.ne);
if (item.source === "audiomack") return await getAMMediaSource(item);
return null;
}
async function searchOneSource(source, query, page = 1) {
if (source === "qq") return (await searchQQ(query, page)).data;
if (source === "netease") return (await searchNetease(query, page)).data;
if (source === "audiomack") return (await searchAudiomack(query, page)).data;
return [];
}
/** 换源结果缓存:记住 key -> 命中的候选曲,避免同一首歌反复搜(URL 会过期,只缓存 item) */
const _altCache = new Map();
const ALT_TTL = 30 * 60 * 1000;
function altCacheGet(key) {
const v = _altCache.get(key);
if (!v) return null;
if (Date.now() - v.ts > ALT_TTL) {
_altCache.delete(key);
return null;
}
return v.item;
}
function altCacheSet(key, item) {
// 简单的容量控制,别让 Map 无限涨
if (_altCache.size > 200) {
const oldest = [..._altCache.entries()].sort((a, b) => a[1].ts - b[1].ts)[0];
if (oldest) _altCache.delete(oldest[0]);
}
_altCache.set(key, { item, ts: Date.now() });
}
/** 换源:按 FALLBACK_ORDER 依次找,每个源取分数最高的几个候选试 */
async function findAlternative(musicItem, quality, cookies, strict) {
const cacheKey = `${musicItem.source}:${musicItem.id}`;
const cached = altCacheGet(cacheKey);
if (cached) {
const hit = await getSourceBySource(cached, quality, cookies).catch(() => null);
if (hit && hit.url) return { ...hit, [_FALLBACK_FROM]: cached };
_altCache.delete(cacheKey);
}
const order = FALLBACK_ORDER[musicItem.source] || [];
// 先用「标题 + 艺人」精确搜,搜不到再退回只用标题
// (标题里可能有"·未验证"标记,搜别的平台时要剔掉)
const cleanTitle = String(musicItem.title || "").replace(/\s*·未验证\s*$/, "");
const queries = [
[cleanTitle, musicItem.artist].filter(Boolean).join(" "),
cleanTitle,
].filter((q, i, arr) => q && arr.indexOf(q) === i);
if (!queries.length) return null;
for (const query of queries) {
for (const src of order) {
let list = [];
try {
list = await searchOneSource(src, query);
} catch (e) {
console.log(`[换源] ${src} 搜索 "${query}" 失败:`, e.message);
continue;
}
const ranked = list
.map(x => ({ item: x, info: matchInfo(musicItem, x, { strict }) }))
.filter(c => c.info)
// 先按身份证据(专辑确认 > 母带 > 无专辑兜底),再按分数
.sort(
(a, b) => tierRank(a.info) - tierRank(b.info) || b.info.score - a.info.score
);
for (const c of ranked.slice(0, 3)) {
const url = await getSourceBySource(c.item, quality, cookies).catch(() => null);
if (url && url.url) {
console.log(
`[换源] "${musicItem.title}" 播放失败 → 用 ${src} 的 "${c.item.title}" (${c.info.tier}, score=${c.info.score}, ${c.item.duration}s, 查:"${query}")`
);
altCacheSet(cacheKey, c.item);
return { ...url, [_FALLBACK_FROM]: c.item };
}
}
}
}
return null;
}
/* ------------------------------ 搜索结果合并去重 ------------------------------ */
/**
* 同一个曲子在不同平台会重复出现(周杰伦 晴天 在三个源里都有)。
* 这里按「标题 + 艺人/时长」聚类,每组只留一条,其余挂到 primary.alts 上,
* 播放时原生放不了就用 alts 里的备选源(见 getMediaSource)。
* alts 是自定义字段,宿主入库是整对象 JSON.stringify,会保留。
*/
// 主条目优先选哪个源(越靠前越优先);再优先非 VIP(可播概率高)
const MERGE_SOURCE_PRIORITY = ["netease", "qq", "audiomack"];
function mergePreferScore(item) {
const p = MERGE_SOURCE_PRIORITY.indexOf(item.source);
// 无专辑信息的条目(翻录/盗传/cover)信息不可信:可以当播放兜底,但绝不能当 primary
return (item.pay === 1 ? 10 : 0) + (String(item.album || "").trim() ? 0 : 20) + (p < 0 ? 99 : p);
}
/** 合并判定:与换源共用 matchInfo,但走严格模式(不接受无专辑的 loose 候选) */
function sameTrack(a, b) {
return !!matchInfo(a, b, { strict: true });
}
function mergeSameTracks(items) {
const groups = [];
for (const it of items) {
const key = normText(it.title);
let target = null;
for (const g of groups) {
if (g.key !== key) continue;
// 必须与组内**每个**成员都相似。用 .some 会被空专辑条目"桥接"传递:
// 正版范特西 ↔ 无专辑cover ↔ 无专辑翻录 串成一组,Live 版也混进来。
if (g.members.every(m => sameTrack(m, it))) {
target = g;
break;
}
}
if (target) target.members.push(it);
else groups.push({ key, members: [it] });
}
return groups.map(g => {
if (g.members.length === 1) return g.members[0];
const sorted = g.members.slice().sort((a, b) => mergePreferScore(a) - mergePreferScore(b));
const primary = sorted[0];
// 备选只保留**其它源**的、每源最多一条(同源重复没有换源价值)
const seenSrc = new Set([primary.source]);
const alts = [];
for (const m of sorted.slice(1)) {
if (seenSrc.has(m.source)) continue;
seenSrc.add(m.source);
alts.push(m);
if (alts.length >= 3) break;
}
const out = { ...primary, _mergedCount: g.members.length };
if (alts.length) out.alts = alts;
return out;
});
}
/* ------------------------------ 插件导出 ------------------------------ */
module.exports = {
platform: "多源歌单",
version: "0.1.5",
appVersion: ">=0.0",
cacheControl: "no-cache",
// id 已带 source 前缀,单主键即可全局唯一
primaryKey: ["id"],
// 声明后宿主会按类型筛选:搜索页只在「音乐」Tab 下出现本插件
supportedSearchType: ["music"],
defaultSearchType: "music",
userVariables: [
{ key: "search_source", name: "搜索源:all / qq / netease / audiomack(默认 all)" },
{ key: "fallback", name: "跨平台换源:on / off(默认 on。原生源放不了时去别的平台找同一首歌)" },
{ key: "merge", name: "搜索结果合并去重:on / off(默认 on。同一首歌只显示一条,其余源作为备选)" },
{ key: "strict_match", name: "严格匹配:on / off(默认 off。on = 换源时拒绝无专辑信息、只靠时长顶上的候选)" },
{ key: "qq_cookie", name: "QQ音乐 Cookie(可选,用于 VIP 歌曲)" },
{ key: "ne_cookie", name: "网易云 Cookie(可选,用于 VIP 歌曲)" },
],
hints: {
importMusicSheet: [
"QQ音乐:分享 → 复制链接,或直接粘贴歌单ID;网页版 y.qq.com 歌单地址均可",
"网易云:分享 → 复制链接(含 163cn.tv 短链),或直接粘贴歌单ID",
"Audiomack:粘贴歌单页地址 https://audiomack.com/<artist>/playlist/<slug>,或直接粘贴数字歌单ID",
"只支持公开歌单;私密/付费内容需要对应平台登录后才能访问",
],
},
/**
* 搜索:插件内部聚合三个源。
* 注意宿主是按插件分 Tab 的,所以这三家的结果会出现在同一个 Tab 里,
* 想分开就得拆成多个插件(每个插件一个 platform)。
*/
async search(query, page, type) {
if (type && type !== "music") {
return { isEnd: true, data: [] };
}
const vars = getUserVariables();
const which = String(vars.search_source || "all").trim().toLowerCase();
const plan = [];
if (which === "all" || which === "qq") plan.push(searchQQ(query, page));
if (which === "all" || which === "netease" || which === "ne") plan.push(searchNetease(query, page));
if (which === "all" || which === "audiomack" || which === "am") plan.push(searchAudiomack(query, page));
if (!plan.length) return { isEnd: true, data: [] };
const settled = await Promise.all(
plan.map(p =>
p.catch(e => {
console.log("[search] 源失败:", e && e.message);
return { data: [], isEnd: true };
})
)
);
const merged = interleave(settled.map(r => r.data || []));
// 合并开关(默认开):同一首歌在多个源出现时只留一条,备选源挂 alts
const merge = String(vars.merge ?? "on").trim().toLowerCase();
const noMerge = merge === "off" || merge === "false" || merge === "0";
const data = noMerge ? merged : mergeSameTracks(merged);
// 透明度标记:无专辑 = 无法确认是哪一版(翻录/翻唱/盗传高发),明示给用户
const UNVERIFIED = " ·未验证";
const marked = data.map(it =>
String(it.album || "").trim() || String(it.title || "").includes(UNVERIFIED)
? it
: { ...it, title: `${it.title}${UNVERIFIED}` }
);
return {
// 所有源都到头了才算 end,否则宿主就不给翻下一页了
isEnd: settled.every(r => r.isEnd),
data: marked,
};
},
/** 导入歌单:按链接特征分发 */
async importMusicSheet(urlLike) {
const text = String(urlLike || "");
const looksQQ = /qq\.com|qqmusic|qq\.cc/i.test(text);
const looksNE = /163\.cn|163\.com|music\.163/i.test(text);
const looksAM = /audiomack/i.test(text);
if (looksAM) return (await importAudiomack(urlLike)) || [];
if (looksQQ && !looksNE) return (await importQQ(urlLike)) || (await importNetease(urlLike)) || [];
if (looksNE && !looksQQ) return (await importNetease(urlLike)) || (await importQQ(urlLike)) || [];
// 没域名信息(纯 id 或纯文本),三家都试
for (const fn of [importNetease, importQQ, importAudiomack]) {
const r = await fn(urlLike).catch(() => null);
if (r && r.length) return r;
}
return [];
},
/** 播放地址:先原生源,失败则按开关去别的平台换源 */
async getMediaSource(musicItem, quality) {
const vars = getUserVariables();
const cookies = { qq: vars.qq_cookie, ne: vars.ne_cookie };
// 严格模式:换源时不要「无专辑字段、只靠时长顶上」的候选(loose 档)
const strict =
String(vars.strict_match ?? "off").trim().toLowerCase() === "on";
// 1. 原生源
const native = await getSourceBySource(musicItem, quality, cookies).catch(e => {
console.log("[原生源] 异常:", e && e.message);
return null;
});
if (native && native.url) return native;
// 2. 换源(默认开,userVariables 里填 off 可关掉)
const fb = String(vars.fallback ?? "on").trim().toLowerCase();
if (fb === "off" || fb === "false" || fb === "0") return null;
// 2a. 优先用搜索合并时挂上的备选源(同一首歌在别的平台的副本,不用再搜一遍)
if (Array.isArray(musicItem.alts) && musicItem.alts.length) {
for (const alt of musicItem.alts) {
const info = matchInfo(musicItem, alt, { strict });
if (!info) {
console.log(`[换源] 跳过不可信备选 ${alt.id}(无法确认是同一录音)`);
continue;
}
const r = await getSourceBySource(alt, quality, cookies).catch(() => null);
if (r && r.url) {
console.log(
`[换源] "${musicItem.title}" 用搜索合并的备选源 ${alt.source} (${info.tier})`
);
return { ...r, [_FALLBACK_FROM]: alt };
}
}
}
// 2b. 现场搜索换源
return await findAlternative(musicItem, quality, cookies, strict).catch(e => {
console.log("[换源] 异常:", e && e.message);
return null;
});
},
/** 歌词:按 source 分发(Audiomack 无公开歌词接口) */
async getLyric(musicItem) {
try {
if (musicItem.source === "qq") {
const res = await http.get(
"https://c.y.qq.com/lyric/fcgi-bin/fcg_query_lyric_new.fcg",
{
headers: QQ_HEADERS,
params: {
songmid: musicItem.songmid,
format: "json",
nobase64: 1,
g_tk: 5381,
},
},
);
const lyric = (res.data && res.data.lyric) || "";
return lyric ? { rawLrc: lyric } : null;
}
if (musicItem.source === "netease") {
const res = await http.get("https://music.163.com/api/song/lyric", {
headers: NE_HEADERS,
params: { id: musicItem.nid, lv: 1, kv: 1, tv: -1 },
});
const lyric = (res.data && res.data.lrc && res.data.lrc.lyric) || "";
return lyric ? { rawLrc: lyric } : null;
}