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