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