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