1import { 2 copyFile, 3 exists, 4 readDir, 5 readFile, 6 unlink, 7 writeFile, 8} from 'react-native-fs'; 9import CryptoJs from 'crypto-js'; 10import dayjs from 'dayjs'; 11import axios from 'axios'; 12import bigInt from 'big-integer'; 13import qs from 'qs'; 14import * as webdav from 'webdav'; 15import {InteractionManager, ToastAndroid} from 'react-native'; 16import pathConst from '@/constants/pathConst'; 17import {compare, satisfies} from 'compare-versions'; 18import DeviceInfo from 'react-native-device-info'; 19import StateMapper from '@/utils/stateMapper'; 20import MediaExtra from './mediaExtra'; 21import {nanoid} from 'nanoid'; 22import {devLog, errorLog, trace} from '../utils/log'; 23import { 24 getInternalData, 25 InternalDataType, 26 isSameMediaItem, 27 resetMediaItem, 28} from '@/utils/mediaItem'; 29import { 30 CacheControl, 31 emptyFunction, 32 internalSerializeKey, 33 localPluginHash, 34 localPluginPlatform, 35} from '@/constants/commonConst'; 36import delay from '@/utils/delay'; 37import * as cheerio from 'cheerio'; 38import CookieManager from '@react-native-cookies/cookies'; 39import he from 'he'; 40import Network from './network'; 41import LocalMusicSheet from './localMusicSheet'; 42import {getInfoAsync} from 'expo-file-system'; 43import Mp3Util from '@/native/mp3Util'; 44import {PluginMeta} from './pluginMeta'; 45import {useEffect, useState} from 'react'; 46import {addFileScheme, getFileName} from '@/utils/fileUtils'; 47import {URL} from 'react-native-url-polyfill'; 48import Base64 from '@/utils/base64'; 49import MediaCache from './mediaCache'; 50import {produce} from 'immer'; 51import objectPath from 'object-path'; 52 53axios.defaults.timeout = 2000; 54 55const sha256 = CryptoJs.SHA256; 56 57export enum PluginStateCode { 58 /** 版本不匹配 */ 59 VersionNotMatch = 'VERSION NOT MATCH', 60 /** 无法解析 */ 61 CannotParse = 'CANNOT PARSE', 62} 63 64const packages: Record<string, any> = { 65 cheerio, 66 'crypto-js': CryptoJs, 67 axios, 68 dayjs, 69 'big-integer': bigInt, 70 qs, 71 he, 72 '@react-native-cookies/cookies': CookieManager, 73 webdav, 74}; 75 76const _require = (packageName: string) => { 77 let pkg = packages[packageName]; 78 pkg.default = pkg; 79 return pkg; 80}; 81 82const _consoleBind = function ( 83 method: 'log' | 'error' | 'info' | 'warn', 84 ...args: any 85) { 86 const fn = console[method]; 87 if (fn) { 88 fn(...args); 89 devLog(method, ...args); 90 } 91}; 92 93const _console = { 94 log: _consoleBind.bind(null, 'log'), 95 warn: _consoleBind.bind(null, 'warn'), 96 info: _consoleBind.bind(null, 'info'), 97 error: _consoleBind.bind(null, 'error'), 98}; 99 100function formatAuthUrl(url: string) { 101 const urlObj = new URL(url); 102 103 try { 104 if (urlObj.username && urlObj.password) { 105 const auth = `Basic ${Base64.btoa( 106 `${decodeURIComponent(urlObj.username)}:${decodeURIComponent( 107 urlObj.password, 108 )}`, 109 )}`; 110 urlObj.username = ''; 111 urlObj.password = ''; 112 113 return { 114 url: urlObj.toString(), 115 auth, 116 }; 117 } 118 } catch (e) { 119 return { 120 url, 121 }; 122 } 123 return { 124 url, 125 }; 126} 127 128//#region 插件类 129export class Plugin { 130 /** 插件名 */ 131 public name: string; 132 /** 插件的hash,作为唯一id */ 133 public hash: string; 134 /** 插件状态:激活、关闭、错误 */ 135 public state: 'enabled' | 'disabled' | 'error'; 136 /** 插件状态信息 */ 137 public stateCode?: PluginStateCode; 138 /** 插件的实例 */ 139 public instance: IPlugin.IPluginInstance; 140 /** 插件路径 */ 141 public path: string; 142 /** 插件方法 */ 143 public methods: PluginMethods; 144 145 constructor( 146 funcCode: string | (() => IPlugin.IPluginInstance), 147 pluginPath: string, 148 ) { 149 this.state = 'enabled'; 150 let _instance: IPlugin.IPluginInstance; 151 const _module: any = {exports: {}}; 152 try { 153 if (typeof funcCode === 'string') { 154 // 插件的环境变量 155 const env = { 156 getUserVariables: () => { 157 return ( 158 PluginMeta.getPluginMeta(this)?.userVariables ?? {} 159 ); 160 }, 161 os: 'android', 162 }; 163 164 // eslint-disable-next-line no-new-func 165 _instance = Function(` 166 'use strict'; 167 return function(require, __musicfree_require, module, exports, console, env, URL) { 168 ${funcCode} 169 } 170 `)()( 171 _require, 172 _require, 173 _module, 174 _module.exports, 175 _console, 176 env, 177 URL, 178 ); 179 if (_module.exports.default) { 180 _instance = _module.exports 181 .default as IPlugin.IPluginInstance; 182 } else { 183 _instance = _module.exports as IPlugin.IPluginInstance; 184 } 185 } else { 186 _instance = funcCode(); 187 } 188 // 插件初始化后的一些操作 189 if (Array.isArray(_instance.userVariables)) { 190 _instance.userVariables = _instance.userVariables.filter( 191 it => it?.key, 192 ); 193 } 194 this.checkValid(_instance); 195 } catch (e: any) { 196 console.log(e); 197 this.state = 'error'; 198 this.stateCode = PluginStateCode.CannotParse; 199 if (e?.stateCode) { 200 this.stateCode = e.stateCode; 201 } 202 errorLog(`${pluginPath}插件无法解析 `, { 203 stateCode: this.stateCode, 204 message: e?.message, 205 stack: e?.stack, 206 }); 207 _instance = e?.instance ?? { 208 _path: '', 209 platform: '', 210 appVersion: '', 211 async getMediaSource() { 212 return null; 213 }, 214 async search() { 215 return {}; 216 }, 217 async getAlbumInfo() { 218 return null; 219 }, 220 }; 221 } 222 this.instance = _instance; 223 this.path = pluginPath; 224 this.name = _instance.platform; 225 if ( 226 this.instance.platform === '' || 227 this.instance.platform === undefined 228 ) { 229 this.hash = ''; 230 } else { 231 if (typeof funcCode === 'string') { 232 this.hash = sha256(funcCode).toString(); 233 } else { 234 this.hash = sha256(funcCode.toString()).toString(); 235 } 236 } 237 238 // 放在最后 239 this.methods = new PluginMethods(this); 240 } 241 242 private checkValid(_instance: IPlugin.IPluginInstance) { 243 /** 版本号校验 */ 244 if ( 245 _instance.appVersion && 246 !satisfies(DeviceInfo.getVersion(), _instance.appVersion) 247 ) { 248 throw { 249 instance: _instance, 250 stateCode: PluginStateCode.VersionNotMatch, 251 }; 252 } 253 return true; 254 } 255} 256//#endregion 257 258//#region 基于插件类封装的方法,供给APP侧直接调用 259/** 有缓存等信息 */ 260class PluginMethods implements IPlugin.IPluginInstanceMethods { 261 private plugin; 262 constructor(plugin: Plugin) { 263 this.plugin = plugin; 264 } 265 /** 搜索 */ 266 async search<T extends ICommon.SupportMediaType>( 267 query: string, 268 page: number, 269 type: T, 270 ): Promise<IPlugin.ISearchResult<T>> { 271 if (!this.plugin.instance.search) { 272 return { 273 isEnd: true, 274 data: [], 275 }; 276 } 277 278 const result = 279 (await this.plugin.instance.search(query, page, type)) ?? {}; 280 if (Array.isArray(result.data)) { 281 result.data.forEach(_ => { 282 resetMediaItem(_, this.plugin.name); 283 }); 284 return { 285 isEnd: result.isEnd ?? true, 286 data: result.data, 287 }; 288 } 289 return { 290 isEnd: true, 291 data: [], 292 }; 293 } 294 295 /** 获取真实源 */ 296 async getMediaSource( 297 musicItem: IMusic.IMusicItemBase, 298 quality: IMusic.IQualityKey = 'standard', 299 retryCount = 1, 300 notUpdateCache = false, 301 ): Promise<IPlugin.IMediaSourceResult | null> { 302 // 1. 本地搜索 其实直接读mediameta就好了 303 const mediaExtra = MediaExtra.get(musicItem); 304 const localPath = 305 mediaExtra?.localPath || 306 getInternalData<string>(musicItem, InternalDataType.LOCALPATH) || 307 getInternalData<string>( 308 LocalMusicSheet.isLocalMusic(musicItem), 309 InternalDataType.LOCALPATH, 310 ); 311 if (localPath && (await getInfoAsync(localPath)).exists) { 312 trace('本地播放', localPath); 313 if (mediaExtra && mediaExtra.localPath !== localPath) { 314 // 修正一下本地数据 315 MediaExtra.update(musicItem, { 316 localPath, 317 }); 318 } 319 return { 320 url: addFileScheme(localPath), 321 }; 322 } else if (mediaExtra?.localPath) { 323 MediaExtra.update(musicItem, { 324 localPath: undefined, 325 }); 326 } 327 328 if (musicItem.platform === localPluginPlatform) { 329 throw new Error('本地音乐不存在'); 330 } 331 // 2. 缓存播放 332 const mediaCache = MediaCache.getMediaCache( 333 musicItem, 334 ) as IMusic.IMusicItem | null; 335 const pluginCacheControl = 336 this.plugin.instance.cacheControl ?? 'no-cache'; 337 if ( 338 mediaCache && 339 mediaCache?.source?.[quality]?.url && 340 (pluginCacheControl === CacheControl.Cache || 341 (pluginCacheControl === CacheControl.NoCache && 342 Network.isOffline())) 343 ) { 344 trace('播放', '缓存播放'); 345 const qualityInfo = mediaCache.source[quality]; 346 return { 347 url: qualityInfo!.url, 348 headers: mediaCache.headers, 349 userAgent: 350 mediaCache.userAgent ?? mediaCache.headers?.['user-agent'], 351 }; 352 } 353 // 3. 插件解析 354 if (!this.plugin.instance.getMediaSource) { 355 const {url, auth} = formatAuthUrl( 356 musicItem?.qualities?.[quality]?.url ?? musicItem.url, 357 ); 358 return { 359 url: url, 360 headers: auth 361 ? { 362 Authorization: auth, 363 } 364 : undefined, 365 }; 366 } 367 try { 368 const {url, headers} = (await this.plugin.instance.getMediaSource( 369 musicItem, 370 quality, 371 )) ?? {url: musicItem?.qualities?.[quality]?.url}; 372 if (!url) { 373 throw new Error('NOT RETRY'); 374 } 375 trace('播放', '插件播放'); 376 const result = { 377 url, 378 headers, 379 userAgent: headers?.['user-agent'], 380 } as IPlugin.IMediaSourceResult; 381 const authFormattedResult = formatAuthUrl(result.url!); 382 if (authFormattedResult.auth) { 383 result.url = authFormattedResult.url; 384 result.headers = { 385 ...(result.headers ?? {}), 386 Authorization: authFormattedResult.auth, 387 }; 388 } 389 390 if ( 391 pluginCacheControl !== CacheControl.NoStore && 392 !notUpdateCache 393 ) { 394 // 更新缓存 395 const cacheSource = { 396 headers: result.headers, 397 userAgent: result.userAgent, 398 url, 399 }; 400 let realMusicItem = { 401 ...musicItem, 402 ...(mediaCache || {}), 403 }; 404 realMusicItem.source = { 405 ...(realMusicItem.source || {}), 406 [quality]: cacheSource, 407 }; 408 409 MediaCache.setMediaCache(realMusicItem); 410 } 411 return result; 412 } catch (e: any) { 413 if (retryCount > 0 && e?.message !== 'NOT RETRY') { 414 await delay(150); 415 return this.getMediaSource(musicItem, quality, --retryCount); 416 } 417 errorLog('获取真实源失败', e?.message); 418 devLog('error', '获取真实源失败', e, e?.message); 419 return null; 420 } 421 } 422 423 /** 获取音乐详情 */ 424 async getMusicInfo( 425 musicItem: ICommon.IMediaBase, 426 ): Promise<Partial<IMusic.IMusicItem> | null> { 427 if (!this.plugin.instance.getMusicInfo) { 428 return null; 429 } 430 try { 431 return ( 432 this.plugin.instance.getMusicInfo( 433 resetMediaItem(musicItem, undefined, true), 434 ) ?? null 435 ); 436 } catch (e: any) { 437 devLog('error', '获取音乐详情失败', e, e?.message); 438 return null; 439 } 440 } 441 442 /** 443 * 444 * getLyric(musicItem) => { 445 * lyric: string; 446 * trans: string; 447 * } 448 * 449 */ 450 /** 获取歌词 */ 451 async getLyric( 452 originalMusicItem: IMusic.IMusicItemBase, 453 ): Promise<ILyric.ILyricSource | null> { 454 // 1.额外存储的meta信息(关联歌词) 455 const meta = MediaExtra.get(originalMusicItem); 456 let musicItem: IMusic.IMusicItem; 457 if (meta && meta.associatedLrc) { 458 musicItem = meta.associatedLrc as IMusic.IMusicItem; 459 } else { 460 musicItem = originalMusicItem as IMusic.IMusicItem; 461 } 462 463 const musicItemCache = MediaCache.getMediaCache( 464 musicItem, 465 ) as IMusic.IMusicItemCache | null; 466 467 /** 原始歌词文本 */ 468 let rawLrc: string | null = musicItem.rawLrc || null; 469 let translation: string | null = null; 470 471 // 2. 缓存歌词 / 对象上本身的歌词 472 if (musicItemCache?.lyric) { 473 // 缓存的远程结果 474 let cacheLyric: ILyric.ILyricSource | null = 475 musicItemCache.lyric || null; 476 // 缓存的本地结果 477 let localLyric: ILyric.ILyricSource | null = 478 musicItemCache.$localLyric || null; 479 480 // 优先用缓存的结果 481 if (cacheLyric.rawLrc || cacheLyric.translation) { 482 return { 483 rawLrc: cacheLyric.rawLrc, 484 translation: cacheLyric.translation, 485 }; 486 } 487 488 // 本地其实是缓存的路径 489 if (localLyric) { 490 let needRefetch = false; 491 if (localLyric.rawLrc && (await exists(localLyric.rawLrc))) { 492 rawLrc = await readFile(localLyric.rawLrc, 'utf8'); 493 } else if (localLyric.rawLrc) { 494 needRefetch = true; 495 } 496 if ( 497 localLyric.translation && 498 (await exists(localLyric.translation)) 499 ) { 500 translation = await readFile( 501 localLyric.translation, 502 'utf8', 503 ); 504 } else if (localLyric.translation) { 505 needRefetch = true; 506 } 507 508 if (!needRefetch && (rawLrc || translation)) { 509 return { 510 rawLrc: rawLrc || undefined, 511 translation: translation || undefined, 512 }; 513 } 514 } 515 } 516 517 // 3. 无缓存歌词/无自带歌词/无本地歌词 518 let lrcSource: ILyric.ILyricSource | null; 519 if (isSameMediaItem(originalMusicItem, musicItem)) { 520 lrcSource = 521 (await this.plugin.instance 522 ?.getLyric?.(resetMediaItem(musicItem, undefined, true)) 523 ?.catch(() => null)) || null; 524 } else { 525 lrcSource = 526 (await PluginManager.getByMedia(musicItem) 527 ?.instance?.getLyric?.( 528 resetMediaItem(musicItem, undefined, true), 529 ) 530 ?.catch(() => null)) || null; 531 } 532 533 if (lrcSource) { 534 rawLrc = lrcSource?.rawLrc || rawLrc; 535 translation = lrcSource?.translation || null; 536 537 const deprecatedLrcUrl = lrcSource?.lrc || musicItem.lrc; 538 539 // 本地的文件名 540 let filename: string | undefined = `${ 541 pathConst.lrcCachePath 542 }${nanoid()}.lrc`; 543 let filenameTrans: string | undefined = `${ 544 pathConst.lrcCachePath 545 }${nanoid()}.lrc`; 546 547 // 旧版本兼容 548 if (!(rawLrc || translation)) { 549 if (deprecatedLrcUrl) { 550 rawLrc = ( 551 await axios 552 .get(deprecatedLrcUrl, {timeout: 3000}) 553 .catch(() => null) 554 )?.data; 555 } else if (musicItem.rawLrc) { 556 rawLrc = musicItem.rawLrc; 557 } 558 } 559 560 if (rawLrc) { 561 await writeFile(filename, rawLrc, 'utf8'); 562 } else { 563 filename = undefined; 564 } 565 if (translation) { 566 await writeFile(filenameTrans, translation, 'utf8'); 567 } else { 568 filenameTrans = undefined; 569 } 570 571 if (rawLrc || translation) { 572 MediaCache.setMediaCache( 573 produce(musicItemCache || musicItem, draft => { 574 musicItemCache?.$localLyric?.rawLrc; 575 objectPath.set(draft, '$localLyric.rawLrc', filename); 576 objectPath.set( 577 draft, 578 '$localLyric.translation', 579 filenameTrans, 580 ); 581 return draft; 582 }), 583 ); 584 return { 585 rawLrc: rawLrc || undefined, 586 translation: translation || undefined, 587 }; 588 } 589 } 590 591 // 6. 如果是本地文件 592 const isDownloaded = LocalMusicSheet.isLocalMusic(originalMusicItem); 593 if ( 594 originalMusicItem.platform !== localPluginPlatform && 595 isDownloaded 596 ) { 597 const res = await localFilePlugin.instance!.getLyric!(isDownloaded); 598 599 console.log('本地文件歌词'); 600 601 if (res) { 602 return res; 603 } 604 } 605 devLog('warn', '无歌词'); 606 607 return null; 608 } 609 610 /** 获取歌词文本 */ 611 async getLyricText( 612 musicItem: IMusic.IMusicItem, 613 ): Promise<string | undefined> { 614 return (await this.getLyric(musicItem))?.rawLrc; 615 } 616 617 /** 获取专辑信息 */ 618 async getAlbumInfo( 619 albumItem: IAlbum.IAlbumItemBase, 620 page: number = 1, 621 ): Promise<IPlugin.IAlbumInfoResult | null> { 622 if (!this.plugin.instance.getAlbumInfo) { 623 return { 624 albumItem, 625 musicList: (albumItem?.musicList ?? []).map( 626 resetMediaItem, 627 this.plugin.name, 628 true, 629 ), 630 isEnd: true, 631 }; 632 } 633 try { 634 const result = await this.plugin.instance.getAlbumInfo( 635 resetMediaItem(albumItem, undefined, true), 636 page, 637 ); 638 if (!result) { 639 throw new Error(); 640 } 641 result?.musicList?.forEach(_ => { 642 resetMediaItem(_, this.plugin.name); 643 _.album = albumItem.title; 644 }); 645 646 if (page <= 1) { 647 // 合并信息 648 return { 649 albumItem: {...albumItem, ...(result?.albumItem ?? {})}, 650 isEnd: result.isEnd === false ? false : true, 651 musicList: result.musicList, 652 }; 653 } else { 654 return { 655 isEnd: result.isEnd === false ? false : true, 656 musicList: result.musicList, 657 }; 658 } 659 } catch (e: any) { 660 trace('获取专辑信息失败', e?.message); 661 devLog('error', '获取专辑信息失败', e, e?.message); 662 663 return null; 664 } 665 } 666 667 /** 获取歌单信息 */ 668 async getMusicSheetInfo( 669 sheetItem: IMusic.IMusicSheetItem, 670 page: number = 1, 671 ): Promise<IPlugin.ISheetInfoResult | null> { 672 if (!this.plugin.instance.getMusicSheetInfo) { 673 return { 674 sheetItem, 675 musicList: sheetItem?.musicList ?? [], 676 isEnd: true, 677 }; 678 } 679 try { 680 const result = await this.plugin.instance?.getMusicSheetInfo?.( 681 resetMediaItem(sheetItem, undefined, true), 682 page, 683 ); 684 if (!result) { 685 throw new Error(); 686 } 687 result?.musicList?.forEach(_ => { 688 resetMediaItem(_, this.plugin.name); 689 }); 690 691 if (page <= 1) { 692 // 合并信息 693 return { 694 sheetItem: {...sheetItem, ...(result?.sheetItem ?? {})}, 695 isEnd: result.isEnd === false ? false : true, 696 musicList: result.musicList, 697 }; 698 } else { 699 return { 700 isEnd: result.isEnd === false ? false : true, 701 musicList: result.musicList, 702 }; 703 } 704 } catch (e: any) { 705 trace('获取歌单信息失败', e, e?.message); 706 devLog('error', '获取歌单信息失败', e, e?.message); 707 708 return null; 709 } 710 } 711 712 /** 查询作者信息 */ 713 async getArtistWorks<T extends IArtist.ArtistMediaType>( 714 artistItem: IArtist.IArtistItem, 715 page: number, 716 type: T, 717 ): Promise<IPlugin.ISearchResult<T>> { 718 if (!this.plugin.instance.getArtistWorks) { 719 return { 720 isEnd: true, 721 data: [], 722 }; 723 } 724 try { 725 const result = await this.plugin.instance.getArtistWorks( 726 artistItem, 727 page, 728 type, 729 ); 730 if (!result.data) { 731 return { 732 isEnd: true, 733 data: [], 734 }; 735 } 736 result.data?.forEach(_ => resetMediaItem(_, this.plugin.name)); 737 return { 738 isEnd: result.isEnd ?? true, 739 data: result.data, 740 }; 741 } catch (e: any) { 742 trace('查询作者信息失败', e?.message); 743 devLog('error', '查询作者信息失败', e, e?.message); 744 745 throw e; 746 } 747 } 748 749 /** 导入歌单 */ 750 async importMusicSheet(urlLike: string): Promise<IMusic.IMusicItem[]> { 751 try { 752 const result = 753 (await this.plugin.instance?.importMusicSheet?.(urlLike)) ?? []; 754 result.forEach(_ => resetMediaItem(_, this.plugin.name)); 755 return result; 756 } catch (e: any) { 757 console.log(e); 758 devLog('error', '导入歌单失败', e, e?.message); 759 760 return []; 761 } 762 } 763 /** 导入单曲 */ 764 async importMusicItem(urlLike: string): Promise<IMusic.IMusicItem | null> { 765 try { 766 const result = await this.plugin.instance?.importMusicItem?.( 767 urlLike, 768 ); 769 if (!result) { 770 throw new Error(); 771 } 772 resetMediaItem(result, this.plugin.name); 773 return result; 774 } catch (e: any) { 775 devLog('error', '导入单曲失败', e, e?.message); 776 777 return null; 778 } 779 } 780 /** 获取榜单 */ 781 async getTopLists(): Promise<IMusic.IMusicSheetGroupItem[]> { 782 try { 783 const result = await this.plugin.instance?.getTopLists?.(); 784 if (!result) { 785 throw new Error(); 786 } 787 return result; 788 } catch (e: any) { 789 devLog('error', '获取榜单失败', e, e?.message); 790 return []; 791 } 792 } 793 /** 获取榜单详情 */ 794 async getTopListDetail( 795 topListItem: IMusic.IMusicSheetItemBase, 796 page: number, 797 ): Promise<IPlugin.ITopListInfoResult> { 798 try { 799 const result = await this.plugin.instance?.getTopListDetail?.( 800 topListItem, 801 page, 802 ); 803 if (!result) { 804 throw new Error(); 805 } 806 if (result.musicList) { 807 result.musicList.forEach(_ => 808 resetMediaItem(_, this.plugin.name), 809 ); 810 } 811 if (result.isEnd !== false) { 812 result.isEnd = true; 813 } 814 return result; 815 } catch (e: any) { 816 devLog('error', '获取榜单详情失败', e, e?.message); 817 return { 818 isEnd: true, 819 topListItem: topListItem as IMusic.IMusicSheetItem, 820 musicList: [], 821 }; 822 } 823 } 824 825 /** 获取推荐歌单的tag */ 826 async getRecommendSheetTags(): Promise<IPlugin.IGetRecommendSheetTagsResult> { 827 try { 828 const result = 829 await this.plugin.instance?.getRecommendSheetTags?.(); 830 if (!result) { 831 throw new Error(); 832 } 833 return result; 834 } catch (e: any) { 835 devLog('error', '获取推荐歌单失败', e, e?.message); 836 return { 837 data: [], 838 }; 839 } 840 } 841 /** 获取某个tag的推荐歌单 */ 842 async getRecommendSheetsByTag( 843 tagItem: ICommon.IUnique, 844 page?: number, 845 ): Promise<ICommon.PaginationResponse<IMusic.IMusicSheetItemBase>> { 846 try { 847 const result = 848 await this.plugin.instance?.getRecommendSheetsByTag?.( 849 tagItem, 850 page ?? 1, 851 ); 852 if (!result) { 853 throw new Error(); 854 } 855 if (result.isEnd !== false) { 856 result.isEnd = true; 857 } 858 if (!result.data) { 859 result.data = []; 860 } 861 result.data.forEach(item => resetMediaItem(item, this.plugin.name)); 862 863 return result; 864 } catch (e: any) { 865 devLog('error', '获取推荐歌单详情失败', e, e?.message); 866 return { 867 isEnd: true, 868 data: [], 869 }; 870 } 871 } 872 873 async getMusicComments( 874 musicItem: IMusic.IMusicItem, 875 ): Promise<ICommon.PaginationResponse<IMedia.IComment>> { 876 const result = await this.plugin.instance?.getMusicComments?.( 877 musicItem, 878 ); 879 if (!result) { 880 throw new Error(); 881 } 882 if (result.isEnd !== false) { 883 result.isEnd = true; 884 } 885 if (!result.data) { 886 result.data = []; 887 } 888 889 return result; 890 } 891} 892//#endregion 893 894let plugins: Array<Plugin> = []; 895const pluginStateMapper = new StateMapper(() => plugins); 896 897//#region 本地音乐插件 898/** 本地插件 */ 899const localFilePlugin = new Plugin(function () { 900 return { 901 platform: localPluginPlatform, 902 _path: '', 903 async getMusicInfo(musicBase) { 904 const localPath = getInternalData<string>( 905 musicBase, 906 InternalDataType.LOCALPATH, 907 ); 908 if (localPath) { 909 const coverImg = await Mp3Util.getMediaCoverImg(localPath); 910 return { 911 artwork: coverImg, 912 }; 913 } 914 return null; 915 }, 916 async getLyric(musicBase) { 917 const localPath = getInternalData<string>( 918 musicBase, 919 InternalDataType.LOCALPATH, 920 ); 921 let rawLrc: string | null = null; 922 if (localPath) { 923 // 读取内嵌歌词 924 try { 925 rawLrc = await Mp3Util.getLyric(localPath); 926 } catch (e) { 927 console.log('读取内嵌歌词失败', e); 928 } 929 if (!rawLrc) { 930 // 读取配置歌词 931 const lastDot = localPath.lastIndexOf('.'); 932 const lrcPath = localPath.slice(0, lastDot) + '.lrc'; 933 934 try { 935 if (await exists(lrcPath)) { 936 rawLrc = await readFile(lrcPath, 'utf8'); 937 } 938 } catch {} 939 } 940 } 941 942 return rawLrc 943 ? { 944 rawLrc, 945 } 946 : null; 947 }, 948 async importMusicItem(urlLike) { 949 let meta: any = {}; 950 try { 951 meta = await Mp3Util.getBasicMeta(urlLike); 952 } catch {} 953 const stat = await getInfoAsync(urlLike, { 954 md5: true, 955 }); 956 let id: string; 957 if (stat.exists) { 958 id = stat.md5 || nanoid(); 959 } else { 960 id = nanoid(); 961 } 962 return { 963 id: id, 964 platform: '本地', 965 title: meta?.title ?? getFileName(urlLike), 966 artist: meta?.artist ?? '未知歌手', 967 duration: parseInt(meta?.duration ?? '0', 10) / 1000, 968 album: meta?.album ?? '未知专辑', 969 artwork: '', 970 [internalSerializeKey]: { 971 localPath: urlLike, 972 }, 973 }; 974 }, 975 async getMediaSource(musicItem, quality) { 976 if (quality === 'standard') { 977 return { 978 url: addFileScheme(musicItem.$?.localPath || musicItem.url), 979 }; 980 } 981 return null; 982 }, 983 }; 984}, ''); 985localFilePlugin.hash = localPluginHash; 986 987//#endregion 988 989async function setup() { 990 const _plugins: Array<Plugin> = []; 991 try { 992 // 加载插件 993 const pluginsPaths = await readDir(pathConst.pluginPath); 994 for (let i = 0; i < pluginsPaths.length; ++i) { 995 const _pluginUrl = pluginsPaths[i]; 996 trace('初始化插件', _pluginUrl); 997 if ( 998 _pluginUrl.isFile() && 999 (_pluginUrl.name?.endsWith?.('.js') || 1000 _pluginUrl.path?.endsWith?.('.js')) 1001 ) { 1002 const funcCode = await readFile(_pluginUrl.path, 'utf8'); 1003 const plugin = new Plugin(funcCode, _pluginUrl.path); 1004 const _pluginIndex = _plugins.findIndex( 1005 p => p.hash === plugin.hash, 1006 ); 1007 if (_pluginIndex !== -1) { 1008 // 重复插件,直接忽略 1009 continue; 1010 } 1011 plugin.hash !== '' && _plugins.push(plugin); 1012 } 1013 } 1014 1015 plugins = _plugins; 1016 /** 初始化meta信息 */ 1017 await PluginMeta.setupMeta(plugins.map(_ => _.name)); 1018 /** 查看一下是否有禁用的标记 */ 1019 const allMeta = PluginMeta.getPluginMetaAll() ?? {}; 1020 for (let plugin of plugins) { 1021 if (allMeta[plugin.name]?.enabled === false) { 1022 plugin.state = 'disabled'; 1023 } 1024 } 1025 pluginStateMapper.notify(); 1026 } catch (e: any) { 1027 ToastAndroid.show( 1028 `插件初始化失败:${e?.message ?? e}`, 1029 ToastAndroid.LONG, 1030 ); 1031 errorLog('插件初始化失败', e?.message); 1032 throw e; 1033 } 1034} 1035 1036interface IInstallPluginConfig { 1037 notCheckVersion?: boolean; 1038} 1039 1040async function installPluginFromRawCode( 1041 funcCode: string, 1042 config?: IInstallPluginConfig, 1043) { 1044 if (funcCode) { 1045 const plugin = new Plugin(funcCode, ''); 1046 const _pluginIndex = plugins.findIndex(p => p.hash === plugin.hash); 1047 if (_pluginIndex !== -1) { 1048 // 静默忽略 1049 return plugin; 1050 } 1051 const oldVersionPlugin = plugins.find(p => p.name === plugin.name); 1052 if (oldVersionPlugin && !config?.notCheckVersion) { 1053 if ( 1054 compare( 1055 oldVersionPlugin.instance.version ?? '', 1056 plugin.instance.version ?? '', 1057 '>', 1058 ) 1059 ) { 1060 throw new Error('已安装更新版本的插件'); 1061 } 1062 } 1063 1064 if (plugin.hash !== '') { 1065 const fn = nanoid(); 1066 if (oldVersionPlugin) { 1067 plugins = plugins.filter(_ => _.hash !== oldVersionPlugin.hash); 1068 try { 1069 await unlink(oldVersionPlugin.path); 1070 } catch {} 1071 } 1072 const pluginPath = `${pathConst.pluginPath}${fn}.js`; 1073 await writeFile(pluginPath, funcCode, 'utf8'); 1074 plugin.path = pluginPath; 1075 plugins = plugins.concat(plugin); 1076 pluginStateMapper.notify(); 1077 return plugin; 1078 } 1079 throw new Error('插件无法解析!'); 1080 } 1081} 1082 1083// 安装插件 1084async function installPlugin( 1085 pluginPath: string, 1086 config?: IInstallPluginConfig, 1087) { 1088 // if (pluginPath.endsWith('.js')) { 1089 const funcCode = await readFile(pluginPath, 'utf8'); 1090 1091 if (funcCode) { 1092 const plugin = new Plugin(funcCode, pluginPath); 1093 const _pluginIndex = plugins.findIndex(p => p.hash === plugin.hash); 1094 if (_pluginIndex !== -1) { 1095 // 静默忽略 1096 return plugin; 1097 } 1098 const oldVersionPlugin = plugins.find(p => p.name === plugin.name); 1099 if (oldVersionPlugin && !config?.notCheckVersion) { 1100 if ( 1101 compare( 1102 oldVersionPlugin.instance.version ?? '', 1103 plugin.instance.version ?? '', 1104 '>', 1105 ) 1106 ) { 1107 throw new Error('已安装更新版本的插件'); 1108 } 1109 } 1110 1111 if (plugin.hash !== '') { 1112 const fn = nanoid(); 1113 if (oldVersionPlugin) { 1114 plugins = plugins.filter(_ => _.hash !== oldVersionPlugin.hash); 1115 try { 1116 await unlink(oldVersionPlugin.path); 1117 } catch {} 1118 } 1119 const _pluginPath = `${pathConst.pluginPath}${fn}.js`; 1120 await copyFile(pluginPath, _pluginPath); 1121 plugin.path = _pluginPath; 1122 plugins = plugins.concat(plugin); 1123 pluginStateMapper.notify(); 1124 return plugin; 1125 } 1126 throw new Error('插件无法解析!'); 1127 } 1128 throw new Error('插件无法识别!'); 1129} 1130 1131const reqHeaders = { 1132 'Cache-Control': 'no-cache', 1133 Pragma: 'no-cache', 1134 Expires: '0', 1135}; 1136 1137async function installPluginFromUrl( 1138 url: string, 1139 config?: IInstallPluginConfig, 1140) { 1141 try { 1142 const funcCode = ( 1143 await axios.get(url, { 1144 headers: reqHeaders, 1145 }) 1146 ).data; 1147 if (funcCode) { 1148 const plugin = new Plugin(funcCode, ''); 1149 const _pluginIndex = plugins.findIndex(p => p.hash === plugin.hash); 1150 if (_pluginIndex !== -1) { 1151 // 静默忽略 1152 return; 1153 } 1154 const oldVersionPlugin = plugins.find(p => p.name === plugin.name); 1155 if (oldVersionPlugin && !config?.notCheckVersion) { 1156 if ( 1157 compare( 1158 oldVersionPlugin.instance.version ?? '', 1159 plugin.instance.version ?? '', 1160 '>', 1161 ) 1162 ) { 1163 throw new Error('已安装更新版本的插件'); 1164 } 1165 } 1166 1167 if (plugin.hash !== '') { 1168 const fn = nanoid(); 1169 const _pluginPath = `${pathConst.pluginPath}${fn}.js`; 1170 await writeFile(_pluginPath, funcCode, 'utf8'); 1171 plugin.path = _pluginPath; 1172 plugins = plugins.concat(plugin); 1173 if (oldVersionPlugin) { 1174 plugins = plugins.filter( 1175 _ => _.hash !== oldVersionPlugin.hash, 1176 ); 1177 try { 1178 await unlink(oldVersionPlugin.path); 1179 } catch {} 1180 } 1181 pluginStateMapper.notify(); 1182 return; 1183 } 1184 throw new Error('插件无法解析!'); 1185 } 1186 } catch (e: any) { 1187 devLog('error', 'URL安装插件失败', e, e?.message); 1188 errorLog('URL安装插件失败', e); 1189 throw new Error(e?.message ?? ''); 1190 } 1191} 1192 1193/** 卸载插件 */ 1194async function uninstallPlugin(hash: string) { 1195 const targetIndex = plugins.findIndex(_ => _.hash === hash); 1196 if (targetIndex !== -1) { 1197 try { 1198 const pluginName = plugins[targetIndex].name; 1199 await unlink(plugins[targetIndex].path); 1200 plugins = plugins.filter(_ => _.hash !== hash); 1201 pluginStateMapper.notify(); 1202 // 防止其他重名 1203 if (plugins.every(_ => _.name !== pluginName)) { 1204 MediaExtra.removeAll(pluginName); 1205 } 1206 } catch {} 1207 } 1208} 1209 1210async function uninstallAllPlugins() { 1211 await Promise.all( 1212 plugins.map(async plugin => { 1213 try { 1214 const pluginName = plugin.name; 1215 await unlink(plugin.path); 1216 MediaExtra.removeAll(pluginName); 1217 } catch (e) {} 1218 }), 1219 ); 1220 plugins = []; 1221 pluginStateMapper.notify(); 1222 1223 /** 清除空余文件,异步做就可以了 */ 1224 readDir(pathConst.pluginPath) 1225 .then(fns => { 1226 fns.forEach(fn => { 1227 unlink(fn.path).catch(emptyFunction); 1228 }); 1229 }) 1230 .catch(emptyFunction); 1231} 1232 1233async function updatePlugin(plugin: Plugin) { 1234 const updateUrl = plugin.instance.srcUrl; 1235 if (!updateUrl) { 1236 throw new Error('没有更新源'); 1237 } 1238 try { 1239 await installPluginFromUrl(updateUrl); 1240 } catch (e: any) { 1241 if (e.message === '插件已安装') { 1242 throw new Error('当前已是最新版本'); 1243 } else { 1244 throw e; 1245 } 1246 } 1247} 1248 1249function getByMedia(mediaItem: ICommon.IMediaBase) { 1250 return getByName(mediaItem?.platform); 1251} 1252 1253function getByHash(hash: string) { 1254 return hash === localPluginHash 1255 ? localFilePlugin 1256 : plugins.find(_ => _.hash === hash); 1257} 1258 1259function getByName(name: string) { 1260 return name === localPluginPlatform 1261 ? localFilePlugin 1262 : plugins.find(_ => _.name === name); 1263} 1264 1265function getValidPlugins() { 1266 return plugins.filter(_ => _.state === 'enabled'); 1267} 1268 1269function getSearchablePlugins(supportedSearchType?: ICommon.SupportMediaType) { 1270 return plugins.filter( 1271 _ => 1272 _.state === 'enabled' && 1273 _.instance.search && 1274 (supportedSearchType && _.instance.supportedSearchType 1275 ? _.instance.supportedSearchType.includes(supportedSearchType) 1276 : true), 1277 ); 1278} 1279 1280function getSortedSearchablePlugins( 1281 supportedSearchType?: ICommon.SupportMediaType, 1282) { 1283 return getSearchablePlugins(supportedSearchType).sort((a, b) => 1284 (PluginMeta.getPluginMeta(a).order ?? Infinity) - 1285 (PluginMeta.getPluginMeta(b).order ?? Infinity) < 1286 0 1287 ? -1 1288 : 1, 1289 ); 1290} 1291 1292function getTopListsablePlugins() { 1293 return plugins.filter(_ => _.state === 'enabled' && _.instance.getTopLists); 1294} 1295 1296function getSortedTopListsablePlugins() { 1297 return getTopListsablePlugins().sort((a, b) => 1298 (PluginMeta.getPluginMeta(a).order ?? Infinity) - 1299 (PluginMeta.getPluginMeta(b).order ?? Infinity) < 1300 0 1301 ? -1 1302 : 1, 1303 ); 1304} 1305 1306function getRecommendSheetablePlugins() { 1307 return plugins.filter( 1308 _ => _.state === 'enabled' && _.instance.getRecommendSheetsByTag, 1309 ); 1310} 1311 1312function getSortedRecommendSheetablePlugins() { 1313 return getRecommendSheetablePlugins().sort((a, b) => 1314 (PluginMeta.getPluginMeta(a).order ?? Infinity) - 1315 (PluginMeta.getPluginMeta(b).order ?? Infinity) < 1316 0 1317 ? -1 1318 : 1, 1319 ); 1320} 1321 1322function useSortedPlugins() { 1323 const _plugins = pluginStateMapper.useMappedState(); 1324 const _pluginMetaAll = PluginMeta.usePluginMetaAll(); 1325 1326 const [sortedPlugins, setSortedPlugins] = useState( 1327 [..._plugins].sort((a, b) => 1328 (_pluginMetaAll[a.name]?.order ?? Infinity) - 1329 (_pluginMetaAll[b.name]?.order ?? Infinity) < 1330 0 1331 ? -1 1332 : 1, 1333 ), 1334 ); 1335 1336 useEffect(() => { 1337 InteractionManager.runAfterInteractions(() => { 1338 setSortedPlugins( 1339 [..._plugins].sort((a, b) => 1340 (_pluginMetaAll[a.name]?.order ?? Infinity) - 1341 (_pluginMetaAll[b.name]?.order ?? Infinity) < 1342 0 1343 ? -1 1344 : 1, 1345 ), 1346 ); 1347 }); 1348 }, [_plugins, _pluginMetaAll]); 1349 1350 return sortedPlugins; 1351} 1352 1353async function setPluginEnabled(plugin: Plugin, enabled?: boolean) { 1354 const target = plugins.find(it => it.hash === plugin.hash); 1355 if (target) { 1356 target.state = enabled ? 'enabled' : 'disabled'; 1357 plugins = [...plugins]; 1358 pluginStateMapper.notify(); 1359 PluginMeta.setPluginMetaProp(plugin, 'enabled', enabled); 1360 } 1361} 1362 1363const PluginManager = { 1364 setup, 1365 installPlugin, 1366 installPluginFromRawCode, 1367 installPluginFromUrl, 1368 updatePlugin, 1369 uninstallPlugin, 1370 getByMedia, 1371 getByHash, 1372 getByName, 1373 getValidPlugins, 1374 getSearchablePlugins, 1375 getSortedSearchablePlugins, 1376 getTopListsablePlugins, 1377 getSortedRecommendSheetablePlugins, 1378 getSortedTopListsablePlugins, 1379 usePlugins: pluginStateMapper.useMappedState, 1380 useSortedPlugins, 1381 uninstallAllPlugins, 1382 setPluginEnabled, 1383}; 1384 1385export default PluginManager; 1386