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 // 1. 本地搜索 其实直接读mediameta就好了 173 const localPath = 174 musicItem?.[internalSymbolKey]?.localPath ?? 175 Download.getDownloaded(musicItem)?.[internalSymbolKey]?.localPath; 176 if (localPath && (await exists(localPath))) { 177 trace('播放', '本地播放'); 178 return { 179 url: localPath, 180 }; 181 } 182 // 2. 缓存播放 183 const mediaCache = Cache.get(musicItem); 184 const pluginCacheControl = this.plugin.instance.cacheControl; 185 if ( 186 mediaCache && 187 mediaCache?.url && 188 (pluginCacheControl === CacheControl.Cache || 189 (pluginCacheControl === CacheControl.NoCache && 190 Network.isOffline())) 191 ) { 192 trace('播放', '缓存播放'); 193 return { 194 url: mediaCache.url, 195 headers: mediaCache.headers, 196 userAgent: 197 mediaCache.userAgent ?? mediaCache.headers?.['user-agent'], 198 }; 199 } 200 // 3. 插件解析 201 if (!this.plugin.instance.getMediaSource) { 202 return {url: musicItem.url}; 203 } 204 try { 205 const {url, headers} = 206 (await this.plugin.instance.getMediaSource(musicItem)) ?? {}; 207 if (!url) { 208 throw new Error(); 209 } 210 trace('播放', '插件播放'); 211 const result = { 212 url, 213 headers, 214 userAgent: headers?.['user-agent'], 215 } as IPlugin.IMediaSourceResult; 216 217 if (pluginCacheControl !== CacheControl.NoStore) { 218 Cache.update(musicItem, result); 219 } 220 221 return result; 222 } catch (e: any) { 223 if (retryCount > 0) { 224 await delay(150); 225 return this.getMediaSource(musicItem, --retryCount); 226 } 227 errorLog('获取真实源失败', e?.message); 228 throw e; 229 } 230 } 231 232 /** 获取音乐详情 */ 233 async getMusicInfo( 234 musicItem: ICommon.IMediaBase, 235 ): Promise<IMusic.IMusicItem | null> { 236 if (!this.plugin.instance.getMusicInfo) { 237 return musicItem as IMusic.IMusicItem; 238 } 239 return ( 240 this.plugin.instance.getMusicInfo( 241 resetMediaItem(musicItem, undefined, true), 242 ) ?? musicItem 243 ); 244 } 245 246 /** 获取歌词 */ 247 async getLyric( 248 musicItem: IMusic.IMusicItemBase, 249 from?: IMusic.IMusicItemBase, 250 ): Promise<ILyric.ILyricSource | null> { 251 // 1.额外存储的meta信息 252 const meta = MediaMeta.get(musicItem); 253 if (meta && meta.associatedLrc) { 254 // 有关联歌词 255 if ( 256 isSameMediaItem(musicItem, from) || 257 isSameMediaItem(meta.associatedLrc, musicItem) 258 ) { 259 // 形成环路,断开当前的环 260 await MediaMeta.update(musicItem, { 261 associatedLrc: undefined, 262 }); 263 // 无歌词 264 return null; 265 } 266 // 获取关联歌词 267 const associatedMeta = MediaMeta.get(meta.associatedLrc) ?? {}; 268 const result = await this.getLyric( 269 {...meta.associatedLrc, ...associatedMeta}, 270 from ?? musicItem, 271 ); 272 if (result) { 273 // 如果有关联歌词,就返回关联歌词,深度优先 274 return result; 275 } 276 } 277 const cache = Cache.get(musicItem); 278 let rawLrc = meta?.rawLrc || musicItem.rawLrc || cache?.rawLrc; 279 let lrcUrl = meta?.lrc || musicItem.lrc || cache?.lrc; 280 // 如果存在文本 281 if (rawLrc) { 282 return { 283 rawLrc, 284 lrc: lrcUrl, 285 }; 286 } 287 // 2.本地缓存 288 const localLrc = 289 meta?.[internalSerialzeKey]?.local?.localLrc || 290 cache?.[internalSerialzeKey]?.local?.localLrc; 291 if (localLrc && (await exists(localLrc))) { 292 rawLrc = await readFile(localLrc, 'utf8'); 293 return { 294 rawLrc, 295 lrc: lrcUrl, 296 }; 297 } 298 // 3.优先使用url 299 if (lrcUrl) { 300 try { 301 // 需要超时时间 axios timeout 但是没生效 302 rawLrc = (await axios.get(lrcUrl, {timeout: 1500})).data; 303 return { 304 rawLrc, 305 lrc: lrcUrl, 306 }; 307 } catch { 308 lrcUrl = undefined; 309 } 310 } 311 // 4. 如果地址失效 312 if (!lrcUrl) { 313 // 插件获得url 314 try { 315 let lrcSource; 316 if (from) { 317 lrcSource = await PluginManager.getByMedia( 318 musicItem, 319 )?.instance?.getLyric?.( 320 resetMediaItem(musicItem, undefined, true), 321 ); 322 } else { 323 lrcSource = await this.plugin.instance?.getLyric?.( 324 resetMediaItem(musicItem, undefined, true), 325 ); 326 } 327 328 rawLrc = lrcSource?.rawLrc; 329 lrcUrl = lrcSource?.lrc; 330 } catch (e: any) { 331 trace('插件获取歌词失败', e?.message, 'error'); 332 } 333 } 334 // 5. 最后一次请求 335 if (rawLrc || lrcUrl) { 336 const filename = `${pathConst.lrcCachePath}${nanoid()}.lrc`; 337 if (lrcUrl) { 338 try { 339 rawLrc = (await axios.get(lrcUrl, {timeout: 1500})).data; 340 } catch {} 341 } 342 if (rawLrc) { 343 await writeFile(filename, rawLrc, 'utf8'); 344 // 写入缓存 345 Cache.update(musicItem, [ 346 [`${internalSerialzeKey}.local.localLrc`, filename], 347 ]); 348 // 如果有meta 349 if (meta) { 350 MediaMeta.update(musicItem, [ 351 [`${internalSerialzeKey}.local.localLrc`, filename], 352 ]); 353 } 354 return { 355 rawLrc, 356 lrc: lrcUrl, 357 }; 358 } 359 } 360 361 return null; 362 } 363 364 /** 获取歌词文本 */ 365 async getLyricText( 366 musicItem: IMusic.IMusicItem, 367 ): Promise<string | undefined> { 368 return (await this.getLyric(musicItem))?.rawLrc; 369 } 370 371 /** 获取专辑信息 */ 372 async getAlbumInfo( 373 albumItem: IAlbum.IAlbumItemBase, 374 ): Promise<IAlbum.IAlbumItem | null> { 375 if (!this.plugin.instance.getAlbumInfo) { 376 return {...albumItem, musicList: []}; 377 } 378 try { 379 const result = await this.plugin.instance.getAlbumInfo( 380 resetMediaItem(albumItem, undefined, true), 381 ); 382 if (!result) { 383 throw new Error(); 384 } 385 result?.musicList?.forEach(_ => { 386 resetMediaItem(_, this.plugin.name); 387 }); 388 389 return {...albumItem, ...result}; 390 } catch { 391 return {...albumItem, musicList: []}; 392 } 393 } 394 395 /** 查询作者信息 */ 396 async queryArtistWorks<T extends IArtist.ArtistMediaType>( 397 artistItem: IArtist.IArtistItem, 398 page: number, 399 type: T, 400 ): Promise<IPlugin.ISearchResult<T>> { 401 if (!this.plugin.instance.queryArtistWorks) { 402 return { 403 isEnd: true, 404 data: [], 405 }; 406 } 407 try { 408 const result = await this.plugin.instance.queryArtistWorks( 409 artistItem, 410 page, 411 type, 412 ); 413 if (!result.data) { 414 return { 415 isEnd: true, 416 data: [], 417 }; 418 } 419 result.data?.forEach(_ => resetMediaItem(_, this.plugin.name)); 420 return { 421 isEnd: result.isEnd ?? true, 422 data: result.data, 423 }; 424 } catch (e) { 425 throw e; 426 } 427 } 428 429 /** 导入歌单 */ 430 async importMusicSheet(urlLike: string): Promise<IMusic.IMusicItem[]> { 431 try { 432 const result = 433 (await this.plugin.instance?.importMusicSheet?.(urlLike)) ?? []; 434 result.forEach(_ => resetMediaItem(_, this.plugin.name)); 435 return result; 436 } catch { 437 return []; 438 } 439 } 440 /** 导入单曲 */ 441 async importMusicItem(urlLike: string): Promise<IMusic.IMusicItem | null> { 442 try { 443 const result = await this.plugin.instance?.importMusicItem?.( 444 urlLike, 445 ); 446 if (!result) { 447 throw new Error(); 448 } 449 resetMediaItem(result, this.plugin.name); 450 return result; 451 } catch { 452 return null; 453 } 454 } 455} 456 457let plugins: Array<Plugin> = []; 458const pluginStateMapper = new StateMapper(() => plugins); 459 460async function setup() { 461 const _plugins: Array<Plugin> = []; 462 try { 463 // 加载插件 464 const pluginsPaths = await readDir(pathConst.pluginPath); 465 for (let i = 0; i < pluginsPaths.length; ++i) { 466 const _pluginUrl = pluginsPaths[i]; 467 trace('初始化插件', _pluginUrl); 468 if ( 469 _pluginUrl.isFile() && 470 (_pluginUrl.name?.endsWith?.('.js') || 471 _pluginUrl.path?.endsWith?.('.js')) 472 ) { 473 const funcCode = await readFile(_pluginUrl.path, 'utf8'); 474 const plugin = new Plugin(funcCode, _pluginUrl.path); 475 const _pluginIndex = _plugins.findIndex( 476 p => p.hash === plugin.hash, 477 ); 478 if (_pluginIndex !== -1) { 479 // 重复插件,直接忽略 480 return; 481 } 482 plugin.hash !== '' && _plugins.push(plugin); 483 } 484 } 485 486 plugins = _plugins; 487 pluginStateMapper.notify(); 488 } catch (e: any) { 489 ToastAndroid.show( 490 `插件初始化失败:${e?.message ?? e}`, 491 ToastAndroid.LONG, 492 ); 493 errorLog('插件初始化失败', e?.message); 494 throw e; 495 } 496} 497 498// 安装插件 499async function installPlugin(pluginPath: string) { 500 if (pluginPath.endsWith('.js')) { 501 const funcCode = await readFile(pluginPath, 'utf8'); 502 const plugin = new Plugin(funcCode, pluginPath); 503 const _pluginIndex = plugins.findIndex(p => p.hash === plugin.hash); 504 if (_pluginIndex !== -1) { 505 throw new Error('插件已安装'); 506 } 507 if (plugin.hash !== '') { 508 const fn = nanoid(); 509 const _pluginPath = `${pathConst.pluginPath}${fn}.js`; 510 await copyFile(pluginPath, _pluginPath); 511 plugin.path = _pluginPath; 512 plugins = plugins.concat(plugin); 513 pluginStateMapper.notify(); 514 return; 515 } 516 throw new Error('插件无法解析'); 517 } 518 throw new Error('插件不存在'); 519} 520 521async function installPluginFromUrl(url: string) { 522 try { 523 const funcCode = (await axios.get(url)).data; 524 if (funcCode) { 525 const plugin = new Plugin(funcCode, ''); 526 const _pluginIndex = plugins.findIndex(p => p.hash === plugin.hash); 527 if (_pluginIndex !== -1) { 528 throw new Error('插件已安装'); 529 } 530 if (plugin.hash !== '') { 531 const fn = nanoid(); 532 const _pluginPath = `${pathConst.pluginPath}${fn}.js`; 533 await writeFile(_pluginPath, funcCode, 'utf8'); 534 plugin.path = _pluginPath; 535 plugins = plugins.concat(plugin); 536 pluginStateMapper.notify(); 537 return; 538 } 539 throw new Error('插件无法解析'); 540 } 541 } catch (e) { 542 errorLog('URL安装插件失败', e); 543 throw new Error('插件安装失败'); 544 } 545} 546 547/** 卸载插件 */ 548async function uninstallPlugin(hash: string) { 549 const targetIndex = plugins.findIndex(_ => _.hash === hash); 550 if (targetIndex !== -1) { 551 try { 552 const pluginName = plugins[targetIndex].name; 553 await unlink(plugins[targetIndex].path); 554 plugins = plugins.filter(_ => _.hash !== hash); 555 pluginStateMapper.notify(); 556 if (plugins.every(_ => _.name !== pluginName)) { 557 await MediaMeta.removePlugin(pluginName); 558 } 559 } catch {} 560 } 561} 562 563function getByMedia(mediaItem: ICommon.IMediaBase) { 564 return getByName(mediaItem.platform); 565} 566 567function getByHash(hash: string) { 568 return plugins.find(_ => _.hash === hash); 569} 570 571function getByName(name: string) { 572 return plugins.find(_ => _.name === name); 573} 574 575function getValidPlugins() { 576 return plugins.filter(_ => _.state === 'enabled'); 577} 578 579const PluginManager = { 580 setup, 581 installPlugin, 582 installPluginFromUrl, 583 uninstallPlugin, 584 getByMedia, 585 getByHash, 586 getByName, 587 getValidPlugins, 588 usePlugins: pluginStateMapper.useMappedState, 589}; 590 591export default PluginManager; 592