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