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