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