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