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, cache} = 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 if (cache !== false) { 217 Cache.update(musicItem, result); 218 } 219 return result; 220 } catch (e: any) { 221 if (retryCount > 0) { 222 await delay(150); 223 return this.getMusicTrack(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 result = await this.getLyric( 266 meta.associatedLrc, 267 from ?? musicItem, 268 ); 269 if (result) { 270 // 如果有关联歌词,就返回关联歌词,深度优先 271 return result; 272 } 273 } 274 const cache = Cache.get(musicItem); 275 let rawLrc = meta?.rawLrc || musicItem.rawLrc || cache?.rawLrc; 276 let lrcUrl = meta?.lrc || musicItem.lrc || cache?.lrc; 277 // 如果存在文本 278 if (rawLrc) { 279 return { 280 rawLrc, 281 lrc: lrcUrl, 282 }; 283 } 284 // 2.本地缓存 285 const localLrc = 286 meta?.[internalSerialzeKey]?.local?.localLrc || 287 cache?.[internalSerialzeKey]?.local?.localLrc; 288 if (localLrc && (await exists(localLrc))) { 289 rawLrc = await readFile(localLrc, 'utf8'); 290 return { 291 rawLrc, 292 lrc: lrcUrl, 293 }; 294 } 295 // 3.优先使用url 296 if (lrcUrl) { 297 try { 298 // 需要超时时间 axios timeout 但是没生效 299 rawLrc = (await axios.get(lrcUrl, {timeout: 1500})).data; 300 return { 301 rawLrc, 302 lrc: lrcUrl, 303 }; 304 } catch { 305 lrcUrl = undefined; 306 } 307 } 308 // 4. 如果地址失效 309 if (!lrcUrl) { 310 // 插件获得url 311 try { 312 const lrcSource = await this.plugin.instance?.getLyric?.( 313 resetMediaItem(musicItem, undefined, true), 314 ); 315 rawLrc = lrcSource?.rawLrc; 316 lrcUrl = lrcSource?.lrc; 317 } catch (e: any) { 318 trace('插件获取歌词失败', e?.message, 'error'); 319 } 320 } 321 // 5. 最后一次请求 322 if (rawLrc || lrcUrl) { 323 const filename = `${pathConst.lrcCachePath}${nanoid()}.lrc`; 324 if (lrcUrl) { 325 try { 326 rawLrc = (await axios.get(lrcUrl, {timeout: 1500})).data; 327 } catch {} 328 } 329 if (rawLrc) { 330 await writeFile(filename, rawLrc, 'utf8'); 331 // 写入缓存 332 Cache.update(musicItem, [ 333 [`${internalSerialzeKey}.local.localLrc`, filename], 334 ]); 335 // 如果有meta 336 if (meta) { 337 MediaMeta.update(musicItem, [ 338 [`${internalSerialzeKey}.local.localLrc`, filename], 339 ]); 340 } 341 return { 342 rawLrc, 343 lrc: lrcUrl, 344 }; 345 } 346 } 347 348 return null; 349 } 350 351 /** 获取歌词文本 */ 352 async getLyricText( 353 musicItem: IMusic.IMusicItem, 354 ): Promise<string | undefined> { 355 return (await this.getLyric(musicItem))?.rawLrc; 356 } 357 358 /** 获取专辑信息 */ 359 async getAlbumInfo( 360 albumItem: IAlbum.IAlbumItemBase, 361 ): Promise<IAlbum.IAlbumItem | null> { 362 if (!this.plugin.instance.getAlbumInfo) { 363 return {...albumItem, musicList: []}; 364 } 365 try { 366 const result = await this.plugin.instance.getAlbumInfo( 367 resetMediaItem(albumItem, undefined, true), 368 ); 369 if (!result) { 370 throw new Error(); 371 } 372 result?.musicList?.forEach(_ => { 373 resetMediaItem(_, this.plugin.name); 374 }); 375 376 return {...albumItem, ...result}; 377 } catch { 378 return {...albumItem, musicList: []}; 379 } 380 } 381 382 /** 查询作者信息 */ 383 async queryArtistWorks<T extends IArtist.ArtistMediaType>( 384 artistItem: IArtist.IArtistItem, 385 page: number, 386 type: T, 387 ): Promise<IPlugin.ISearchResult<T>> { 388 if (!this.plugin.instance.queryArtistWorks) { 389 return { 390 isEnd: true, 391 data: [], 392 }; 393 } 394 try { 395 const result = await this.plugin.instance.queryArtistWorks( 396 artistItem, 397 page, 398 type, 399 ); 400 if (!result.data) { 401 return { 402 isEnd: true, 403 data: [], 404 }; 405 } 406 result.data?.forEach(_ => resetMediaItem(_, this.plugin.name)); 407 return { 408 isEnd: result.isEnd ?? true, 409 data: result.data, 410 }; 411 } catch (e) { 412 throw e; 413 } 414 } 415} 416 417let plugins: Array<Plugin> = []; 418const pluginStateMapper = new StateMapper(() => plugins); 419 420async function setup() { 421 const _plugins: Array<Plugin> = []; 422 try { 423 // 加载插件 424 const pluginsPaths = await readDir(pathConst.pluginPath); 425 for (let i = 0; i < pluginsPaths.length; ++i) { 426 const _pluginUrl = pluginsPaths[i]; 427 428 if (_pluginUrl.isFile() && _pluginUrl.name.endsWith('.js')) { 429 const funcCode = await readFile(_pluginUrl.path, 'utf8'); 430 const plugin = new Plugin(funcCode, _pluginUrl.path); 431 const _pluginIndex = _plugins.findIndex( 432 p => p.hash === plugin.hash, 433 ); 434 if (_pluginIndex !== -1) { 435 // 重复插件,直接忽略 436 return; 437 } 438 plugin.hash !== '' && _plugins.push(plugin); 439 } 440 } 441 442 plugins = _plugins; 443 pluginStateMapper.notify(); 444 } catch (e: any) { 445 ToastAndroid.show( 446 `插件初始化失败:${e?.message ?? e}`, 447 ToastAndroid.LONG, 448 ); 449 errorLog('插件初始化失败', e?.message); 450 throw e; 451 } 452} 453 454// 安装插件 455async function installPlugin(pluginPath: string) { 456 if (pluginPath.endsWith('.js') && (await exists(pluginPath))) { 457 const funcCode = await readFile(pluginPath, 'utf8'); 458 const plugin = new Plugin(funcCode, pluginPath); 459 const _pluginIndex = plugins.findIndex(p => p.hash === plugin.hash); 460 if (_pluginIndex !== -1) { 461 return; 462 } 463 if (plugin.hash !== '') { 464 const fn = nanoid(); 465 const _pluginPath = `${pathConst.pluginPath}${fn}.js`; 466 await copyFile(pluginPath, _pluginPath); 467 plugin.path = _pluginPath; 468 plugins = plugins.concat(plugin); 469 pluginStateMapper.notify(); 470 } 471 } 472} 473 474/** 卸载插件 */ 475async function uninstallPlugin(hash: string) { 476 const targetIndex = plugins.findIndex(_ => _.hash === hash); 477 if (targetIndex !== -1) { 478 try { 479 await unlink(plugins[targetIndex].path); 480 plugins = plugins.filter(_ => _.hash !== hash); 481 pluginStateMapper.notify(); 482 } catch {} 483 } 484} 485 486function getByMedia(mediaItem: ICommon.IMediaBase) { 487 return getByName(mediaItem.platform); 488} 489 490function getByHash(hash: string) { 491 return plugins.find(_ => _.hash === hash); 492} 493 494function getByName(name: string) { 495 return plugins.find(_ => _.name === name); 496} 497 498function getValidPlugins() { 499 return plugins.filter(_ => _.state === 'enabled'); 500} 501 502const PluginManager = { 503 setup, 504 installPlugin, 505 uninstallPlugin, 506 getByMedia, 507 getByHash, 508 getByName, 509 getValidPlugins, 510 usePlugins: pluginStateMapper.useMappedState, 511}; 512 513export default PluginManager; 514