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