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