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