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 71 this.checkValid(_instance); 72 } catch (e: any) { 73 this.state = 'error'; 74 this.stateCode = PluginStateCode.CannotParse; 75 if (e?.stateCode) { 76 this.stateCode = e.stateCode; 77 } 78 errorLog(`${pluginPath}插件无法解析 `, { 79 stateCode: this.stateCode, 80 message: e?.message, 81 stack: e?.stack, 82 }); 83 _instance = e?.instance ?? { 84 _path: '', 85 platform: '', 86 appVersion: '', 87 async getMusicTrack() { 88 return null; 89 }, 90 async search() { 91 return {}; 92 }, 93 async getAlbumInfo() { 94 return null; 95 }, 96 }; 97 } 98 this.instance = _instance; 99 this.path = pluginPath; 100 this.name = _instance.platform; 101 if (this.instance.platform === '') { 102 this.hash = ''; 103 } else { 104 this.hash = sha256(funcCode).toString(); 105 } 106 107 // 放在最后 108 this.methods = new PluginMethods(this); 109 } 110 111 private checkValid(_instance: IPlugin.IPluginInstance) { 112 // 总不会一个都没有吧 113 const keys: Array<keyof IPlugin.IPluginInstance> = [ 114 'getAlbumInfo', 115 'search', 116 'getMusicTrack', 117 ]; 118 if (keys.every(k => !_instance[k])) { 119 throw { 120 instance: _instance, 121 stateCode: PluginStateCode.NotComplete, 122 }; 123 } 124 /** 版本号校验 */ 125 if ( 126 _instance.appVersion && 127 !satisfies(DeviceInfo.getVersion(), _instance.appVersion) 128 ) { 129 throw { 130 instance: _instance, 131 stateCode: PluginStateCode.VersionNotMatch, 132 }; 133 } 134 return true; 135 } 136} 137 138/** 有缓存等信息 */ 139class PluginMethods implements IPlugin.IPluginInstanceMethods { 140 private plugin; 141 constructor(plugin: Plugin) { 142 this.plugin = plugin; 143 } 144 /** 搜索 */ 145 async search<T extends ICommon.SupportMediaType>( 146 query: string, 147 page: number, 148 type: T, 149 ): Promise<IPlugin.ISearchResult<T>> { 150 if (!this.plugin.instance.search) { 151 return { 152 isEnd: true, 153 data: [], 154 }; 155 } 156 157 const result = 158 (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: 197 mediaCache.userAgent ?? mediaCache.headers?.['user-agent'], 198 }; 199 } 200 // 3. 插件解析 201 if (!this.plugin.instance.getMusicTrack) { 202 return {url: musicItem.url}; 203 } 204 try { 205 const {url, headers} = 206 (await this.plugin.instance.getMusicTrack(musicItem)) ?? {}; 207 if (!url) { 208 throw new Error(); 209 } 210 trace('播放', '插件播放'); 211 const result = { 212 url, 213 headers, 214 userAgent: headers?.['user-agent'], 215 }; 216 217 Cache.update(musicItem, result); 218 return result; 219 } catch (e: any) { 220 if (retryCount > 0) { 221 await delay(150); 222 return this.getMusicTrack(musicItem, --retryCount); 223 } 224 errorLog('获取真实源失败', e?.message); 225 throw e; 226 } 227 } 228 229 /** 获取音乐详情 */ 230 async getMusicInfo( 231 musicItem: ICommon.IMediaBase, 232 ): Promise<IMusic.IMusicItem | null> { 233 if (!this.plugin.instance.getMusicInfo) { 234 return musicItem as IMusic.IMusicItem; 235 } 236 return ( 237 this.plugin.instance.getMusicInfo( 238 resetMediaItem(musicItem, undefined, true), 239 ) ?? musicItem 240 ); 241 } 242 243 /** 获取歌词 */ 244 async getLyric( 245 musicItem: IMusic.IMusicItemBase, 246 from?: IMusic.IMusicItemBase, 247 ): Promise<ILyric.ILyricSource | null> { 248 // 1.额外存储的meta信息 249 const meta = MediaMeta.get(musicItem); 250 if (meta && meta.associatedLrc) { 251 // 有关联歌词 252 if ( 253 isSameMediaItem(musicItem, from) || 254 isSameMediaItem(meta.associatedLrc, musicItem) 255 ) { 256 // 形成环路,断开当前的环 257 await MediaMeta.update(musicItem, { 258 associatedLrc: undefined, 259 }); 260 // 无歌词 261 return null; 262 } 263 // 获取关联歌词 264 const result = await this.getLyric( 265 meta.associatedLrc, 266 from ?? musicItem, 267 ); 268 if (result) { 269 // 如果有关联歌词,就返回关联歌词,深度优先 270 return result; 271 } 272 } 273 const cache = Cache.get(musicItem); 274 let rawLrc = meta?.rawLrc || musicItem.rawLrc || cache?.rawLrc; 275 let lrcUrl = meta?.lrc || musicItem.lrc || cache?.lrc; 276 // 如果存在文本 277 if (rawLrc) { 278 return { 279 rawLrc, 280 lrc: lrcUrl, 281 }; 282 } 283 // 2.本地缓存 284 const localLrc = 285 meta?.[internalSerialzeKey]?.local?.localLrc || 286 cache?.[internalSerialzeKey]?.local?.localLrc; 287 if (localLrc && (await exists(localLrc))) { 288 rawLrc = await readFile(localLrc, 'utf8'); 289 return { 290 rawLrc, 291 lrc: lrcUrl, 292 }; 293 } 294 // 3.优先使用url 295 if (lrcUrl) { 296 try { 297 // 需要超时时间 axios timeout 但是没生效 298 rawLrc = (await axios.get(lrcUrl)).data; 299 return { 300 rawLrc, 301 lrc: lrcUrl, 302 }; 303 } catch { 304 lrcUrl = undefined; 305 } 306 } 307 // 4. 如果地址失效 308 if (!lrcUrl) { 309 // 插件获得url 310 try { 311 const lrcSource = await this.plugin.instance?.getLyric?.( 312 resetMediaItem(musicItem, undefined, true), 313 ); 314 rawLrc = lrcSource?.rawLrc; 315 lrcUrl = lrcSource?.lrc; 316 } catch (e: any) { 317 trace('插件获取歌词失败', e?.message, 'error'); 318 } 319 } 320 // 5. 最后一次请求 321 if (rawLrc || lrcUrl) { 322 const filename = `${pathConst.lrcCachePath}${nanoid()}.lrc`; 323 if (lrcUrl) { 324 try { 325 rawLrc = (await axios.get(lrcUrl)).data; 326 } catch {} 327 } 328 if (rawLrc) { 329 await writeFile(filename, rawLrc, 'utf8'); 330 // 写入缓存 331 Cache.update(musicItem, [ 332 [`${internalSerialzeKey}.local.localLrc`, filename], 333 ]); 334 // 如果有meta 335 if (meta) { 336 MediaMeta.update(musicItem, [ 337 [`${internalSerialzeKey}.local.localLrc`, filename], 338 ]); 339 } 340 return { 341 rawLrc, 342 lrc: lrcUrl, 343 }; 344 } 345 } 346 347 return null; 348 } 349 350 /** 获取歌词文本 */ 351 async getLyricText( 352 musicItem: IMusic.IMusicItem, 353 ): Promise<string | undefined> { 354 return (await this.getLyric(musicItem))?.rawLrc; 355 } 356 357 /** 获取专辑信息 */ 358 async getAlbumInfo( 359 albumItem: IAlbum.IAlbumItemBase, 360 ): Promise<IAlbum.IAlbumItem | null> { 361 if (!this.plugin.instance.getAlbumInfo) { 362 return {...albumItem, musicList: []}; 363 } 364 try { 365 const result = await this.plugin.instance.getAlbumInfo( 366 resetMediaItem(albumItem, undefined, true), 367 ); 368 if (!result) { 369 throw new Error(); 370 } 371 result?.musicList?.forEach(_ => { 372 resetMediaItem(_, this.plugin.name); 373 }); 374 375 return {...albumItem, ...result}; 376 } catch { 377 return {...albumItem, musicList: []}; 378 } 379 } 380 381 /** 查询作者信息 */ 382 async queryArtistWorks<T extends IArtist.ArtistMediaType>( 383 artistItem: IArtist.IArtistItem, 384 page: number, 385 type: T, 386 ): Promise<IPlugin.ISearchResult<T>> { 387 if (!this.plugin.instance.queryArtistWorks) { 388 return { 389 isEnd: true, 390 data: [], 391 }; 392 } 393 try { 394 const result = await this.plugin.instance.queryArtistWorks( 395 artistItem, 396 page, 397 type, 398 ); 399 if (!result.data) { 400 return { 401 isEnd: true, 402 data: [], 403 }; 404 } 405 result.data?.forEach(_ => resetMediaItem(_, this.plugin.name)); 406 return { 407 isEnd: result.isEnd ?? true, 408 data: result.data, 409 }; 410 } catch (e) { 411 throw e; 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 throw e; 448 } 449} 450 451// 安装插件 452async function installPlugin(pluginPath: string) { 453 if (pluginPath.endsWith('.js') && (await exists(pluginPath))) { 454 const funcCode = await readFile(pluginPath, 'utf8'); 455 const plugin = new Plugin(funcCode, pluginPath); 456 const _pluginIndex = plugins.findIndex(p => p.hash === plugin.hash); 457 if (_pluginIndex !== -1) { 458 return; 459 } 460 if (plugin.hash !== '') { 461 const fn = nanoid(); 462 const _pluginPath = `${pathConst.pluginPath}${fn}.js`; 463 await copyFile(pluginPath, _pluginPath); 464 plugin.path = _pluginPath; 465 plugins = plugins.concat(plugin); 466 pluginStateMapper.notify(); 467 } 468 } 469} 470 471/** 卸载插件 */ 472async function uninstallPlugin(hash: string) { 473 const targetIndex = plugins.findIndex(_ => _.hash === hash); 474 if (targetIndex !== -1) { 475 try { 476 await unlink(plugins[targetIndex].path); 477 plugins = plugins.filter(_ => _.hash !== hash); 478 pluginStateMapper.notify(); 479 } catch {} 480 } 481} 482 483function getByMedia(mediaItem: ICommon.IMediaBase) { 484 return getByName(mediaItem.platform); 485} 486 487function getByHash(hash: string) { 488 return plugins.find(_ => _.hash === hash); 489} 490 491function getByName(name: string) { 492 return plugins.find(_ => _.name === name); 493} 494 495function getValidPlugins() { 496 return plugins.filter(_ => _.state === 'enabled'); 497} 498 499const PluginManager = { 500 setup, 501 installPlugin, 502 uninstallPlugin, 503 getByMedia, 504 getByHash, 505 getByName, 506 getValidPlugins, 507 usePlugins: pluginStateMapper.useMappedState, 508}; 509 510export default PluginManager; 511