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 {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 headers: mediaCache.headers, 244 userAgent: 245 mediaCache.userAgent ?? mediaCache.headers?.['user-agent'], 246 }; 247 } 248 // 3. 插件解析 249 if (!this.plugin.instance.getMediaSource) { 250 return {url: musicItem.url}; 251 } 252 try { 253 const {url, headers} = 254 (await this.plugin.instance.getMediaSource(musicItem)) ?? {}; 255 if (!url) { 256 throw new Error(); 257 } 258 trace('播放', '插件播放'); 259 const result = { 260 url, 261 headers, 262 userAgent: headers?.['user-agent'], 263 } as IPlugin.IMediaSourceResult; 264 265 if (pluginCacheControl !== CacheControl.NoStore) { 266 Cache.update(musicItem, result); 267 } 268 269 return result; 270 } catch (e: any) { 271 if (retryCount > 0) { 272 await delay(150); 273 return this.getMediaSource(musicItem, --retryCount); 274 } 275 errorLog('获取真实源失败', e?.message); 276 throw e; 277 } 278 } 279 280 /** 获取音乐详情 */ 281 async getMusicInfo( 282 musicItem: ICommon.IMediaBase, 283 ): Promise<Partial<IMusic.IMusicItem> | null> { 284 if (!this.plugin.instance.getMusicInfo) { 285 return {}; 286 } 287 try { 288 return ( 289 this.plugin.instance.getMusicInfo( 290 resetMediaItem(musicItem, undefined, true), 291 ) ?? {} 292 ); 293 } catch (e) { 294 return {}; 295 } 296 } 297 298 /** 获取歌词 */ 299 async getLyric( 300 musicItem: IMusic.IMusicItemBase, 301 from?: IMusic.IMusicItemBase, 302 ): Promise<ILyric.ILyricSource | null> { 303 // 1.额外存储的meta信息 304 const meta = MediaMeta.get(musicItem); 305 if (meta && meta.associatedLrc) { 306 // 有关联歌词 307 if ( 308 isSameMediaItem(musicItem, from) || 309 isSameMediaItem(meta.associatedLrc, musicItem) 310 ) { 311 // 形成环路,断开当前的环 312 await MediaMeta.update(musicItem, { 313 associatedLrc: undefined, 314 }); 315 // 无歌词 316 return null; 317 } 318 // 获取关联歌词 319 const associatedMeta = MediaMeta.get(meta.associatedLrc) ?? {}; 320 const result = await this.getLyric( 321 {...meta.associatedLrc, ...associatedMeta}, 322 from ?? musicItem, 323 ); 324 if (result) { 325 // 如果有关联歌词,就返回关联歌词,深度优先 326 return result; 327 } 328 } 329 const cache = Cache.get(musicItem); 330 let rawLrc = meta?.rawLrc || musicItem.rawLrc || cache?.rawLrc; 331 let lrcUrl = meta?.lrc || musicItem.lrc || cache?.lrc; 332 // 如果存在文本 333 if (rawLrc) { 334 return { 335 rawLrc, 336 lrc: lrcUrl, 337 }; 338 } 339 // 2.本地缓存 340 const localLrc = 341 meta?.[internalSerializeKey]?.local?.localLrc || 342 cache?.[internalSerializeKey]?.local?.localLrc; 343 if (localLrc && (await exists(localLrc))) { 344 rawLrc = await readFile(localLrc, 'utf8'); 345 return { 346 rawLrc, 347 lrc: lrcUrl, 348 }; 349 } 350 // 3.优先使用url 351 if (lrcUrl) { 352 try { 353 // 需要超时时间 axios timeout 但是没生效 354 rawLrc = (await axios.get(lrcUrl, {timeout: 1500})).data; 355 return { 356 rawLrc, 357 lrc: lrcUrl, 358 }; 359 } catch { 360 lrcUrl = undefined; 361 } 362 } 363 // 4. 如果地址失效 364 if (!lrcUrl) { 365 // 插件获得url 366 try { 367 let lrcSource; 368 if (from) { 369 lrcSource = await PluginManager.getByMedia( 370 musicItem, 371 )?.instance?.getLyric?.( 372 resetMediaItem(musicItem, undefined, true), 373 ); 374 } else { 375 lrcSource = await this.plugin.instance?.getLyric?.( 376 resetMediaItem(musicItem, undefined, true), 377 ); 378 } 379 380 rawLrc = lrcSource?.rawLrc; 381 lrcUrl = lrcSource?.lrc; 382 } catch (e: any) { 383 trace('插件获取歌词失败', e?.message, 'error'); 384 } 385 } 386 // 5. 最后一次请求 387 if (rawLrc || lrcUrl) { 388 const filename = `${pathConst.lrcCachePath}${nanoid()}.lrc`; 389 if (lrcUrl) { 390 try { 391 rawLrc = (await axios.get(lrcUrl, {timeout: 1500})).data; 392 } catch {} 393 } 394 if (rawLrc) { 395 await writeFile(filename, rawLrc, 'utf8'); 396 // 写入缓存 397 Cache.update(musicItem, [ 398 [`${internalSerializeKey}.local.localLrc`, filename], 399 ]); 400 // 如果有meta 401 if (meta) { 402 MediaMeta.update(musicItem, [ 403 [`${internalSerializeKey}.local.localLrc`, filename], 404 ]); 405 } 406 return { 407 rawLrc, 408 lrc: lrcUrl, 409 }; 410 } 411 } 412 413 return null; 414 } 415 416 /** 获取歌词文本 */ 417 async getLyricText( 418 musicItem: IMusic.IMusicItem, 419 ): Promise<string | undefined> { 420 return (await this.getLyric(musicItem))?.rawLrc; 421 } 422 423 /** 获取专辑信息 */ 424 async getAlbumInfo( 425 albumItem: IAlbum.IAlbumItemBase, 426 ): Promise<IAlbum.IAlbumItem | null> { 427 if (!this.plugin.instance.getAlbumInfo) { 428 return {...albumItem, musicList: []}; 429 } 430 try { 431 const result = await this.plugin.instance.getAlbumInfo( 432 resetMediaItem(albumItem, undefined, true), 433 ); 434 if (!result) { 435 throw new Error(); 436 } 437 result?.musicList?.forEach(_ => { 438 resetMediaItem(_, this.plugin.name); 439 }); 440 441 return {...albumItem, ...result}; 442 } catch (e: any) { 443 trace('获取专辑信息失败', e?.message); 444 return {...albumItem, musicList: []}; 445 } 446 } 447 448 /** 查询作者信息 */ 449 async getArtistWorks<T extends IArtist.ArtistMediaType>( 450 artistItem: IArtist.IArtistItem, 451 page: number, 452 type: T, 453 ): Promise<IPlugin.ISearchResult<T>> { 454 if (!this.plugin.instance.getArtistWorks) { 455 return { 456 isEnd: true, 457 data: [], 458 }; 459 } 460 try { 461 const result = await this.plugin.instance.getArtistWorks( 462 artistItem, 463 page, 464 type, 465 ); 466 if (!result.data) { 467 return { 468 isEnd: true, 469 data: [], 470 }; 471 } 472 result.data?.forEach(_ => resetMediaItem(_, this.plugin.name)); 473 return { 474 isEnd: result.isEnd ?? true, 475 data: result.data, 476 }; 477 } catch (e: any) { 478 trace('查询作者信息失败', e?.message); 479 throw e; 480 } 481 } 482 483 /** 导入歌单 */ 484 async importMusicSheet(urlLike: string): Promise<IMusic.IMusicItem[]> { 485 try { 486 const result = 487 (await this.plugin.instance?.importMusicSheet?.(urlLike)) ?? []; 488 result.forEach(_ => resetMediaItem(_, this.plugin.name)); 489 return result; 490 } catch (e) { 491 console.log(e); 492 return []; 493 } 494 } 495 /** 导入单曲 */ 496 async importMusicItem(urlLike: string): Promise<IMusic.IMusicItem | null> { 497 try { 498 const result = await this.plugin.instance?.importMusicItem?.( 499 urlLike, 500 ); 501 if (!result) { 502 throw new Error(); 503 } 504 resetMediaItem(result, this.plugin.name); 505 return result; 506 } catch { 507 return null; 508 } 509 } 510} 511//#endregion 512 513let plugins: Array<Plugin> = []; 514const pluginStateMapper = new StateMapper(() => plugins); 515 516//#region 本地音乐插件 517/** 本地插件 */ 518const localFilePlugin = new Plugin(function () { 519 return { 520 platform: localPluginPlatform, 521 _path: '', 522 async getMusicInfo(musicBase) { 523 const localPath = getInternalData<string>( 524 musicBase, 525 InternalDataType.LOCALPATH, 526 ); 527 if (localPath) { 528 const coverImg = await Mp3Util.getMediaCoverImg(localPath); 529 return { 530 artwork: coverImg, 531 }; 532 } 533 return null; 534 }, 535 async getLyric(musicBase) { 536 const localPath = getInternalData<string>( 537 musicBase, 538 InternalDataType.LOCALPATH, 539 ); 540 if (localPath) { 541 const rawLrc = await Mp3Util.getLyric(localPath); 542 return { 543 rawLrc, 544 }; 545 } 546 return null; 547 }, 548 }; 549}, ''); 550localFilePlugin.hash = localPluginHash; 551 552//#endregion 553 554async function setup() { 555 const _plugins: Array<Plugin> = []; 556 try { 557 // 加载插件 558 const pluginsPaths = await readDir(pathConst.pluginPath); 559 for (let i = 0; i < pluginsPaths.length; ++i) { 560 const _pluginUrl = pluginsPaths[i]; 561 trace('初始化插件', _pluginUrl); 562 if ( 563 _pluginUrl.isFile() && 564 (_pluginUrl.name?.endsWith?.('.js') || 565 _pluginUrl.path?.endsWith?.('.js')) 566 ) { 567 const funcCode = await readFile(_pluginUrl.path, 'utf8'); 568 const plugin = new Plugin(funcCode, _pluginUrl.path); 569 const _pluginIndex = _plugins.findIndex( 570 p => p.hash === plugin.hash, 571 ); 572 if (_pluginIndex !== -1) { 573 // 重复插件,直接忽略 574 return; 575 } 576 plugin.hash !== '' && _plugins.push(plugin); 577 } 578 } 579 580 plugins = _plugins; 581 pluginStateMapper.notify(); 582 /** 初始化meta信息 */ 583 PluginMeta.setupMeta(plugins.map(_ => _.name)); 584 } catch (e: any) { 585 ToastAndroid.show( 586 `插件初始化失败:${e?.message ?? e}`, 587 ToastAndroid.LONG, 588 ); 589 errorLog('插件初始化失败', e?.message); 590 throw e; 591 } 592} 593 594// 安装插件 595async function installPlugin(pluginPath: string) { 596 // if (pluginPath.endsWith('.js')) { 597 const funcCode = await readFile(pluginPath, 'utf8'); 598 const plugin = new Plugin(funcCode, pluginPath); 599 const _pluginIndex = plugins.findIndex(p => p.hash === plugin.hash); 600 if (_pluginIndex !== -1) { 601 throw new Error('插件已安装'); 602 } 603 if (plugin.hash !== '') { 604 const fn = nanoid(); 605 const _pluginPath = `${pathConst.pluginPath}${fn}.js`; 606 await copyFile(pluginPath, _pluginPath); 607 plugin.path = _pluginPath; 608 plugins = plugins.concat(plugin); 609 pluginStateMapper.notify(); 610 return; 611 } 612 throw new Error('插件无法解析'); 613 // } 614 // throw new Error('插件不存在'); 615} 616 617async function installPluginFromUrl(url: string) { 618 try { 619 const funcCode = (await axios.get(url)).data; 620 if (funcCode) { 621 const plugin = new Plugin(funcCode, ''); 622 const _pluginIndex = plugins.findIndex(p => p.hash === plugin.hash); 623 if (_pluginIndex !== -1) { 624 // 静默忽略 625 return; 626 } 627 const oldVersionPlugin = plugins.find(p => p.name === plugin.name); 628 if (oldVersionPlugin) { 629 if ( 630 compare( 631 oldVersionPlugin.instance.version ?? '', 632 plugin.instance.version ?? '', 633 '>', 634 ) 635 ) { 636 throw new Error('已安装更新版本的插件'); 637 } 638 } 639 640 if (plugin.hash !== '') { 641 const fn = nanoid(); 642 const _pluginPath = `${pathConst.pluginPath}${fn}.js`; 643 await writeFile(_pluginPath, funcCode, 'utf8'); 644 plugin.path = _pluginPath; 645 plugins = plugins.concat(plugin); 646 if (oldVersionPlugin) { 647 plugins = plugins.filter( 648 _ => _.hash !== oldVersionPlugin.hash, 649 ); 650 try { 651 await unlink(oldVersionPlugin.path); 652 } catch {} 653 } 654 pluginStateMapper.notify(); 655 return; 656 } 657 throw new Error('插件无法解析!'); 658 } 659 } catch (e: any) { 660 errorLog('URL安装插件失败', e); 661 throw new Error(e?.message ?? ''); 662 } 663} 664 665/** 卸载插件 */ 666async function uninstallPlugin(hash: string) { 667 const targetIndex = plugins.findIndex(_ => _.hash === hash); 668 if (targetIndex !== -1) { 669 try { 670 const pluginName = plugins[targetIndex].name; 671 await unlink(plugins[targetIndex].path); 672 plugins = plugins.filter(_ => _.hash !== hash); 673 pluginStateMapper.notify(); 674 if (plugins.every(_ => _.name !== pluginName)) { 675 await MediaMeta.removePlugin(pluginName); 676 } 677 } catch {} 678 } 679} 680 681async function uninstallAllPlugins() { 682 await Promise.all( 683 plugins.map(async plugin => { 684 try { 685 const pluginName = plugin.name; 686 await unlink(plugin.path); 687 await MediaMeta.removePlugin(pluginName); 688 } catch (e) {} 689 }), 690 ); 691 plugins = []; 692 pluginStateMapper.notify(); 693 694 /** 清除空余文件,异步做就可以了 */ 695 readDir(pathConst.pluginPath) 696 .then(fns => { 697 fns.forEach(fn => { 698 unlink(fn.path).catch(emptyFunction); 699 }); 700 }) 701 .catch(emptyFunction); 702} 703 704async function updatePlugin(plugin: Plugin) { 705 const updateUrl = plugin.instance.srcUrl; 706 if (!updateUrl) { 707 throw new Error('没有更新源'); 708 } 709 try { 710 await installPluginFromUrl(updateUrl); 711 } catch (e: any) { 712 if (e.message === '插件已安装') { 713 throw new Error('当前已是最新版本'); 714 } else { 715 throw e; 716 } 717 } 718} 719 720function getByMedia(mediaItem: ICommon.IMediaBase) { 721 return getByName(mediaItem.platform); 722} 723 724function getByHash(hash: string) { 725 return hash === localPluginHash 726 ? localFilePlugin 727 : plugins.find(_ => _.hash === hash); 728} 729 730function getByName(name: string) { 731 return name === localPluginPlatform 732 ? localFilePlugin 733 : plugins.find(_ => _.name === name); 734} 735 736function getValidPlugins() { 737 return plugins.filter(_ => _.state === 'enabled'); 738} 739 740function getSearchablePlugins() { 741 return plugins.filter(_ => _.state === 'enabled' && _.instance.search); 742} 743 744function getSortedSearchablePlugins() { 745 return getSearchablePlugins().sort((a, b) => 746 (PluginMeta.getPluginMeta(a).order ?? Infinity) - 747 (PluginMeta.getPluginMeta(b).order ?? Infinity) < 748 0 749 ? -1 750 : 1, 751 ); 752} 753 754function useSortedPlugins() { 755 const _plugins = pluginStateMapper.useMappedState(); 756 const _pluginMetaAll = PluginMeta.usePluginMetaAll(); 757 758 const [sortedPlugins, setSortedPlugins] = useState( 759 [..._plugins].sort((a, b) => 760 (_pluginMetaAll[a.name]?.order ?? Infinity) - 761 (_pluginMetaAll[b.name]?.order ?? Infinity) < 762 0 763 ? -1 764 : 1, 765 ), 766 ); 767 768 useEffect(() => { 769 setSortedPlugins( 770 [..._plugins].sort((a, b) => 771 (_pluginMetaAll[a.name]?.order ?? Infinity) - 772 (_pluginMetaAll[b.name]?.order ?? Infinity) < 773 0 774 ? -1 775 : 1, 776 ), 777 ); 778 }, [_plugins, _pluginMetaAll]); 779 780 return sortedPlugins; 781} 782 783const PluginManager = { 784 setup, 785 installPlugin, 786 installPluginFromUrl, 787 updatePlugin, 788 uninstallPlugin, 789 getByMedia, 790 getByHash, 791 getByName, 792 getValidPlugins, 793 getSearchablePlugins, 794 getSortedSearchablePlugins, 795 usePlugins: pluginStateMapper.useMappedState, 796 useSortedPlugins, 797 uninstallAllPlugins, 798}; 799 800export default PluginManager; 801