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