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