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 {InteractionManager, ToastAndroid} from 'react-native'; 15import pathConst from '@/constants/pathConst'; 16import {compare, satisfies} from 'compare-versions'; 17import DeviceInfo from 'react-native-device-info'; 18import StateMapper from '@/utils/stateMapper'; 19import MediaMeta from './mediaMeta'; 20import {nanoid} from 'nanoid'; 21import {errorLog, trace} from '../utils/log'; 22import Cache from './cache'; 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 {FileSystem} from 'react-native-file-access'; 43import Mp3Util from '@/native/mp3Util'; 44import {PluginMeta} from './pluginMeta'; 45import {useEffect, useState} from 'react'; 46 47axios.defaults.timeout = 1500; 48 49const sha256 = CryptoJs.SHA256; 50 51export enum PluginStateCode { 52 /** 版本不匹配 */ 53 VersionNotMatch = 'VERSION NOT MATCH', 54 /** 无法解析 */ 55 CannotParse = 'CANNOT PARSE', 56} 57 58//#region 插件类 59export class Plugin { 60 /** 插件名 */ 61 public name: string; 62 /** 插件的hash,作为唯一id */ 63 public hash: string; 64 /** 插件状态:激活、关闭、错误 */ 65 public state: 'enabled' | 'disabled' | 'error'; 66 /** 插件支持的搜索类型 */ 67 public supportedSearchType?: string; 68 /** 插件状态信息 */ 69 public stateCode?: PluginStateCode; 70 /** 插件的实例 */ 71 public instance: IPlugin.IPluginInstance; 72 /** 插件路径 */ 73 public path: string; 74 /** 插件方法 */ 75 public methods: PluginMethods; 76 /** 用户输入 */ 77 public userEnv?: Record<string, string>; 78 79 constructor( 80 funcCode: string | (() => IPlugin.IPluginInstance), 81 pluginPath: string, 82 ) { 83 this.state = 'enabled'; 84 let _instance: IPlugin.IPluginInstance; 85 try { 86 if (typeof funcCode === 'string') { 87 // eslint-disable-next-line no-new-func 88 _instance = Function(` 89 'use strict'; 90 try { 91 return ${funcCode}; 92 } catch(e) { 93 return null; 94 } 95 `)()({ 96 CryptoJs, 97 axios, 98 dayjs, 99 cheerio, 100 bigInt, 101 qs, 102 he, 103 CookieManager: { 104 flush: CookieManager.flush, 105 get: CookieManager.get, 106 }, 107 }); 108 } else { 109 _instance = funcCode(); 110 } 111 this.checkValid(_instance); 112 } catch (e: any) { 113 this.state = 'error'; 114 this.stateCode = PluginStateCode.CannotParse; 115 if (e?.stateCode) { 116 this.stateCode = e.stateCode; 117 } 118 errorLog(`${pluginPath}插件无法解析 `, { 119 stateCode: this.stateCode, 120 message: e?.message, 121 stack: e?.stack, 122 }); 123 _instance = e?.instance ?? { 124 _path: '', 125 platform: '', 126 appVersion: '', 127 async getMediaSource() { 128 return null; 129 }, 130 async search() { 131 return {}; 132 }, 133 async getAlbumInfo() { 134 return null; 135 }, 136 }; 137 } 138 this.instance = _instance; 139 this.path = pluginPath; 140 this.name = _instance.platform; 141 if (this.instance.platform === '') { 142 this.hash = ''; 143 } else { 144 if (typeof funcCode === 'string') { 145 this.hash = sha256(funcCode).toString(); 146 } else { 147 this.hash = sha256(funcCode.toString()).toString(); 148 } 149 } 150 151 // 放在最后 152 this.methods = new PluginMethods(this); 153 } 154 155 private checkValid(_instance: IPlugin.IPluginInstance) { 156 /** 版本号校验 */ 157 if ( 158 _instance.appVersion && 159 !satisfies(DeviceInfo.getVersion(), _instance.appVersion) 160 ) { 161 throw { 162 instance: _instance, 163 stateCode: PluginStateCode.VersionNotMatch, 164 }; 165 } 166 return true; 167 } 168} 169//#endregion 170 171//#region 基于插件类封装的方法,供给APP侧直接调用 172/** 有缓存等信息 */ 173class PluginMethods implements IPlugin.IPluginInstanceMethods { 174 private plugin; 175 constructor(plugin: Plugin) { 176 this.plugin = plugin; 177 } 178 /** 搜索 */ 179 async search<T extends ICommon.SupportMediaType>( 180 query: string, 181 page: number, 182 type: T, 183 ): Promise<IPlugin.ISearchResult<T>> { 184 if (!this.plugin.instance.search) { 185 return { 186 isEnd: true, 187 data: [], 188 }; 189 } 190 191 const result = 192 (await this.plugin.instance.search(query, page, type)) ?? {}; 193 if (Array.isArray(result.data)) { 194 result.data.forEach(_ => { 195 resetMediaItem(_, this.plugin.name); 196 }); 197 return { 198 isEnd: result.isEnd ?? true, 199 data: result.data, 200 }; 201 } 202 return { 203 isEnd: true, 204 data: [], 205 }; 206 } 207 208 /** 获取真实源 */ 209 async getMediaSource( 210 musicItem: IMusic.IMusicItemBase, 211 retryCount = 1, 212 ): Promise<IPlugin.IMediaSourceResult> { 213 // 1. 本地搜索 其实直接读mediameta就好了 214 const localPath = 215 getInternalData<string>(musicItem, InternalDataType.LOCALPATH) ?? 216 getInternalData<string>( 217 LocalMusicSheet.isLocalMusic(musicItem), 218 InternalDataType.LOCALPATH, 219 ); 220 if (localPath && (await FileSystem.exists(localPath))) { 221 trace('本地播放', localPath); 222 return { 223 url: localPath, 224 }; 225 } 226 if (musicItem.platform === localPluginPlatform) { 227 throw new Error('本地音乐不存在'); 228 } 229 // 2. 缓存播放 230 const mediaCache = Cache.get(musicItem); 231 const pluginCacheControl = 232 this.plugin.instance.cacheControl ?? 'no-cache'; 233 if ( 234 mediaCache && 235 mediaCache?.url && 236 (pluginCacheControl === CacheControl.Cache || 237 (pluginCacheControl === CacheControl.NoCache && 238 Network.isOffline())) 239 ) { 240 trace('播放', '缓存播放'); 241 return { 242 url: mediaCache.url, 243 qualities: mediaCache?.qualities, 244 headers: mediaCache.headers, 245 userAgent: 246 mediaCache.userAgent ?? mediaCache.headers?.['user-agent'], 247 }; 248 } 249 // 3. 插件解析 250 if (!this.plugin.instance.getMediaSource) { 251 return {url: musicItem.url}; 252 } 253 try { 254 const {url, headers, qualities} = 255 (await this.plugin.instance.getMediaSource(musicItem)) ?? {}; 256 if (!url) { 257 throw new Error(); 258 } 259 trace('播放', '插件播放'); 260 const result = { 261 url, 262 headers, 263 userAgent: headers?.['user-agent'], 264 qualities, 265 } as IPlugin.IMediaSourceResult; 266 267 if (pluginCacheControl !== CacheControl.NoStore) { 268 Cache.update(musicItem, result); 269 } 270 271 return result; 272 } catch (e: any) { 273 if (retryCount > 0) { 274 await delay(150); 275 return this.getMediaSource(musicItem, --retryCount); 276 } 277 errorLog('获取真实源失败', e?.message); 278 throw e; 279 } 280 } 281 282 /** 获取音乐详情 */ 283 async getMusicInfo( 284 musicItem: ICommon.IMediaBase, 285 ): Promise<Partial<IMusic.IMusicItem> | null> { 286 if (!this.plugin.instance.getMusicInfo) { 287 return null; 288 } 289 try { 290 return ( 291 this.plugin.instance.getMusicInfo( 292 resetMediaItem(musicItem, undefined, true), 293 ) ?? null 294 ); 295 } catch (e) { 296 return null; 297 } 298 } 299 300 /** 获取歌词 */ 301 async getLyric( 302 musicItem: IMusic.IMusicItemBase, 303 from?: IMusic.IMusicItemBase, 304 ): Promise<ILyric.ILyricSource | null> { 305 // 1.额外存储的meta信息 306 const meta = MediaMeta.get(musicItem); 307 if (meta && meta.associatedLrc) { 308 // 有关联歌词 309 if ( 310 isSameMediaItem(musicItem, from) || 311 isSameMediaItem(meta.associatedLrc, musicItem) 312 ) { 313 // 形成环路,断开当前的环 314 await MediaMeta.update(musicItem, { 315 associatedLrc: undefined, 316 }); 317 // 无歌词 318 return null; 319 } 320 // 获取关联歌词 321 const associatedMeta = MediaMeta.get(meta.associatedLrc) ?? {}; 322 const result = await this.getLyric( 323 {...meta.associatedLrc, ...associatedMeta}, 324 from ?? musicItem, 325 ); 326 if (result) { 327 // 如果有关联歌词,就返回关联歌词,深度优先 328 return result; 329 } 330 } 331 const cache = Cache.get(musicItem); 332 let rawLrc = meta?.rawLrc || musicItem.rawLrc || cache?.rawLrc; 333 let lrcUrl = meta?.lrc || musicItem.lrc || cache?.lrc; 334 // 如果存在文本 335 if (rawLrc) { 336 return { 337 rawLrc, 338 lrc: lrcUrl, 339 }; 340 } 341 // 2.本地缓存 342 const localLrc = 343 meta?.[internalSerializeKey]?.local?.localLrc || 344 cache?.[internalSerializeKey]?.local?.localLrc; 345 if (localLrc && (await exists(localLrc))) { 346 rawLrc = await readFile(localLrc, 'utf8'); 347 return { 348 rawLrc, 349 lrc: lrcUrl, 350 }; 351 } 352 // 3.优先使用url 353 if (lrcUrl) { 354 try { 355 // 需要超时时间 axios timeout 但是没生效 356 rawLrc = (await axios.get(lrcUrl, {timeout: 1500})).data; 357 return { 358 rawLrc, 359 lrc: lrcUrl, 360 }; 361 } catch { 362 lrcUrl = undefined; 363 } 364 } 365 // 4. 如果地址失效 366 if (!lrcUrl) { 367 // 插件获得url 368 try { 369 let lrcSource; 370 if (from) { 371 lrcSource = await PluginManager.getByMedia( 372 musicItem, 373 )?.instance?.getLyric?.( 374 resetMediaItem(musicItem, undefined, true), 375 ); 376 } else { 377 lrcSource = await this.plugin.instance?.getLyric?.( 378 resetMediaItem(musicItem, undefined, true), 379 ); 380 } 381 382 rawLrc = lrcSource?.rawLrc; 383 lrcUrl = lrcSource?.lrc; 384 } catch (e: any) { 385 trace('插件获取歌词失败', e?.message, 'error'); 386 } 387 } 388 // 5. 最后一次请求 389 if (rawLrc || lrcUrl) { 390 const filename = `${pathConst.lrcCachePath}${nanoid()}.lrc`; 391 if (lrcUrl) { 392 try { 393 rawLrc = (await axios.get(lrcUrl, {timeout: 1500})).data; 394 } catch {} 395 } 396 if (rawLrc) { 397 await writeFile(filename, rawLrc, 'utf8'); 398 // 写入缓存 399 Cache.update(musicItem, [ 400 [`${internalSerializeKey}.local.localLrc`, filename], 401 ]); 402 // 如果有meta 403 if (meta) { 404 MediaMeta.update(musicItem, [ 405 [`${internalSerializeKey}.local.localLrc`, filename], 406 ]); 407 } 408 return { 409 rawLrc, 410 lrc: lrcUrl, 411 }; 412 } 413 } 414 415 return null; 416 } 417 418 /** 获取歌词文本 */ 419 async getLyricText( 420 musicItem: IMusic.IMusicItem, 421 ): Promise<string | undefined> { 422 return (await this.getLyric(musicItem))?.rawLrc; 423 } 424 425 /** 获取专辑信息 */ 426 async getAlbumInfo( 427 albumItem: IAlbum.IAlbumItemBase, 428 ): Promise<IAlbum.IAlbumItem | null> { 429 if (!this.plugin.instance.getAlbumInfo) { 430 return {...albumItem, musicList: []}; 431 } 432 try { 433 const result = await this.plugin.instance.getAlbumInfo( 434 resetMediaItem(albumItem, undefined, true), 435 ); 436 if (!result) { 437 throw new Error(); 438 } 439 result?.musicList?.forEach(_ => { 440 resetMediaItem(_, this.plugin.name); 441 }); 442 443 return {...albumItem, ...result}; 444 } catch (e: any) { 445 trace('获取专辑信息失败', e?.message); 446 return {...albumItem, musicList: []}; 447 } 448 } 449 450 /** 查询作者信息 */ 451 async getArtistWorks<T extends IArtist.ArtistMediaType>( 452 artistItem: IArtist.IArtistItem, 453 page: number, 454 type: T, 455 ): Promise<IPlugin.ISearchResult<T>> { 456 if (!this.plugin.instance.getArtistWorks) { 457 return { 458 isEnd: true, 459 data: [], 460 }; 461 } 462 try { 463 const result = await this.plugin.instance.getArtistWorks( 464 artistItem, 465 page, 466 type, 467 ); 468 if (!result.data) { 469 return { 470 isEnd: true, 471 data: [], 472 }; 473 } 474 result.data?.forEach(_ => resetMediaItem(_, this.plugin.name)); 475 return { 476 isEnd: result.isEnd ?? true, 477 data: result.data, 478 }; 479 } catch (e: any) { 480 trace('查询作者信息失败', e?.message); 481 throw e; 482 } 483 } 484 485 /** 导入歌单 */ 486 async importMusicSheet(urlLike: string): Promise<IMusic.IMusicItem[]> { 487 try { 488 const result = 489 (await this.plugin.instance?.importMusicSheet?.(urlLike)) ?? []; 490 result.forEach(_ => resetMediaItem(_, this.plugin.name)); 491 return result; 492 } catch (e) { 493 console.log(e); 494 return []; 495 } 496 } 497 /** 导入单曲 */ 498 async importMusicItem(urlLike: string): Promise<IMusic.IMusicItem | null> { 499 try { 500 const result = await this.plugin.instance?.importMusicItem?.( 501 urlLike, 502 ); 503 if (!result) { 504 throw new Error(); 505 } 506 resetMediaItem(result, this.plugin.name); 507 return result; 508 } catch { 509 return null; 510 } 511 } 512} 513//#endregion 514 515let plugins: Array<Plugin> = []; 516const pluginStateMapper = new StateMapper(() => plugins); 517 518//#region 本地音乐插件 519/** 本地插件 */ 520const localFilePlugin = new Plugin(function () { 521 return { 522 platform: localPluginPlatform, 523 _path: '', 524 async getMusicInfo(musicBase) { 525 const localPath = getInternalData<string>( 526 musicBase, 527 InternalDataType.LOCALPATH, 528 ); 529 if (localPath) { 530 const coverImg = await Mp3Util.getMediaCoverImg(localPath); 531 return { 532 artwork: coverImg, 533 }; 534 } 535 return null; 536 }, 537 async getLyric(musicBase) { 538 const localPath = getInternalData<string>( 539 musicBase, 540 InternalDataType.LOCALPATH, 541 ); 542 if (localPath) { 543 const rawLrc = await Mp3Util.getLyric(localPath); 544 return { 545 rawLrc, 546 }; 547 } 548 return null; 549 }, 550 }; 551}, ''); 552localFilePlugin.hash = localPluginHash; 553 554//#endregion 555 556async function setup() { 557 const _plugins: Array<Plugin> = []; 558 try { 559 // 加载插件 560 const pluginsPaths = await readDir(pathConst.pluginPath); 561 for (let i = 0; i < pluginsPaths.length; ++i) { 562 const _pluginUrl = pluginsPaths[i]; 563 trace('初始化插件', _pluginUrl); 564 if ( 565 _pluginUrl.isFile() && 566 (_pluginUrl.name?.endsWith?.('.js') || 567 _pluginUrl.path?.endsWith?.('.js')) 568 ) { 569 const funcCode = await readFile(_pluginUrl.path, 'utf8'); 570 const plugin = new Plugin(funcCode, _pluginUrl.path); 571 const _pluginIndex = _plugins.findIndex( 572 p => p.hash === plugin.hash, 573 ); 574 if (_pluginIndex !== -1) { 575 // 重复插件,直接忽略 576 return; 577 } 578 plugin.hash !== '' && _plugins.push(plugin); 579 } 580 } 581 582 plugins = _plugins; 583 pluginStateMapper.notify(); 584 /** 初始化meta信息 */ 585 PluginMeta.setupMeta(plugins.map(_ => _.name)); 586 } catch (e: any) { 587 ToastAndroid.show( 588 `插件初始化失败:${e?.message ?? e}`, 589 ToastAndroid.LONG, 590 ); 591 errorLog('插件初始化失败', e?.message); 592 throw e; 593 } 594} 595 596// 安装插件 597async function installPlugin(pluginPath: string) { 598 // if (pluginPath.endsWith('.js')) { 599 const funcCode = await readFile(pluginPath, 'utf8'); 600 const plugin = new Plugin(funcCode, pluginPath); 601 const _pluginIndex = plugins.findIndex(p => p.hash === plugin.hash); 602 if (_pluginIndex !== -1) { 603 throw new Error('插件已安装'); 604 } 605 if (plugin.hash !== '') { 606 const fn = nanoid(); 607 const _pluginPath = `${pathConst.pluginPath}${fn}.js`; 608 await copyFile(pluginPath, _pluginPath); 609 plugin.path = _pluginPath; 610 plugins = plugins.concat(plugin); 611 pluginStateMapper.notify(); 612 return; 613 } 614 throw new Error('插件无法解析'); 615 // } 616 // throw new Error('插件不存在'); 617} 618 619async function installPluginFromUrl(url: string) { 620 try { 621 const funcCode = (await axios.get(url)).data; 622 if (funcCode) { 623 const plugin = new Plugin(funcCode, ''); 624 const _pluginIndex = plugins.findIndex(p => p.hash === plugin.hash); 625 if (_pluginIndex !== -1) { 626 // 静默忽略 627 return; 628 } 629 const oldVersionPlugin = plugins.find(p => p.name === plugin.name); 630 if (oldVersionPlugin) { 631 if ( 632 compare( 633 oldVersionPlugin.instance.version ?? '', 634 plugin.instance.version ?? '', 635 '>', 636 ) 637 ) { 638 throw new Error('已安装更新版本的插件'); 639 } 640 } 641 642 if (plugin.hash !== '') { 643 const fn = nanoid(); 644 const _pluginPath = `${pathConst.pluginPath}${fn}.js`; 645 await writeFile(_pluginPath, funcCode, 'utf8'); 646 plugin.path = _pluginPath; 647 plugins = plugins.concat(plugin); 648 if (oldVersionPlugin) { 649 plugins = plugins.filter( 650 _ => _.hash !== oldVersionPlugin.hash, 651 ); 652 try { 653 await unlink(oldVersionPlugin.path); 654 } catch {} 655 } 656 pluginStateMapper.notify(); 657 return; 658 } 659 throw new Error('插件无法解析!'); 660 } 661 } catch (e: any) { 662 errorLog('URL安装插件失败', e); 663 throw new Error(e?.message ?? ''); 664 } 665} 666 667/** 卸载插件 */ 668async function uninstallPlugin(hash: string) { 669 const targetIndex = plugins.findIndex(_ => _.hash === hash); 670 if (targetIndex !== -1) { 671 try { 672 const pluginName = plugins[targetIndex].name; 673 await unlink(plugins[targetIndex].path); 674 plugins = plugins.filter(_ => _.hash !== hash); 675 pluginStateMapper.notify(); 676 if (plugins.every(_ => _.name !== pluginName)) { 677 await MediaMeta.removePlugin(pluginName); 678 } 679 } catch {} 680 } 681} 682 683async function uninstallAllPlugins() { 684 await Promise.all( 685 plugins.map(async plugin => { 686 try { 687 const pluginName = plugin.name; 688 await unlink(plugin.path); 689 await MediaMeta.removePlugin(pluginName); 690 } catch (e) {} 691 }), 692 ); 693 plugins = []; 694 pluginStateMapper.notify(); 695 696 /** 清除空余文件,异步做就可以了 */ 697 readDir(pathConst.pluginPath) 698 .then(fns => { 699 fns.forEach(fn => { 700 unlink(fn.path).catch(emptyFunction); 701 }); 702 }) 703 .catch(emptyFunction); 704} 705 706async function updatePlugin(plugin: Plugin) { 707 const updateUrl = plugin.instance.srcUrl; 708 if (!updateUrl) { 709 throw new Error('没有更新源'); 710 } 711 try { 712 await installPluginFromUrl(updateUrl); 713 } catch (e: any) { 714 if (e.message === '插件已安装') { 715 throw new Error('当前已是最新版本'); 716 } else { 717 throw e; 718 } 719 } 720} 721 722function getByMedia(mediaItem: ICommon.IMediaBase) { 723 return getByName(mediaItem?.platform); 724} 725 726function getByHash(hash: string) { 727 return hash === localPluginHash 728 ? localFilePlugin 729 : plugins.find(_ => _.hash === hash); 730} 731 732function getByName(name: string) { 733 return name === localPluginPlatform 734 ? localFilePlugin 735 : plugins.find(_ => _.name === name); 736} 737 738function getValidPlugins() { 739 return plugins.filter(_ => _.state === 'enabled'); 740} 741 742function getSearchablePlugins() { 743 return plugins.filter(_ => _.state === 'enabled' && _.instance.search); 744} 745 746function getSortedSearchablePlugins() { 747 return getSearchablePlugins().sort((a, b) => 748 (PluginMeta.getPluginMeta(a).order ?? Infinity) - 749 (PluginMeta.getPluginMeta(b).order ?? Infinity) < 750 0 751 ? -1 752 : 1, 753 ); 754} 755 756function useSortedPlugins() { 757 const _plugins = pluginStateMapper.useMappedState(); 758 const _pluginMetaAll = PluginMeta.usePluginMetaAll(); 759 760 const [sortedPlugins, setSortedPlugins] = useState( 761 [..._plugins].sort((a, b) => 762 (_pluginMetaAll[a.name]?.order ?? Infinity) - 763 (_pluginMetaAll[b.name]?.order ?? Infinity) < 764 0 765 ? -1 766 : 1, 767 ), 768 ); 769 770 useEffect(() => { 771 InteractionManager.runAfterInteractions(() => { 772 setSortedPlugins( 773 [..._plugins].sort((a, b) => 774 (_pluginMetaAll[a.name]?.order ?? Infinity) - 775 (_pluginMetaAll[b.name]?.order ?? Infinity) < 776 0 777 ? -1 778 : 1, 779 ), 780 ); 781 }); 782 }, [_plugins, _pluginMetaAll]); 783 784 return sortedPlugins; 785} 786 787const PluginManager = { 788 setup, 789 installPlugin, 790 installPluginFromUrl, 791 updatePlugin, 792 uninstallPlugin, 793 getByMedia, 794 getByHash, 795 getByName, 796 getValidPlugins, 797 getSearchablePlugins, 798 getSortedSearchablePlugins, 799 usePlugins: pluginStateMapper.useMappedState, 800 useSortedPlugins, 801 uninstallAllPlugins, 802}; 803 804export default PluginManager; 805