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 CannotParse = 'CANNOT PARSE', 35} 36 37export class Plugin { 38 /** 插件名 */ 39 public name: string; 40 /** 插件的hash,作为唯一id */ 41 public hash: string; 42 /** 插件状态:激活、关闭、错误 */ 43 public state: 'enabled' | 'disabled' | 'error'; 44 /** 插件支持的搜索类型 */ 45 public supportedSearchType?: string; 46 /** 插件状态信息 */ 47 public stateCode?: PluginStateCode; 48 /** 插件的实例 */ 49 public instance: IPlugin.IPluginInstance; 50 /** 插件路径 */ 51 public path: string; 52 /** 插件方法 */ 53 public methods: PluginMethods; 54 55 constructor(funcCode: string, pluginPath: string) { 56 this.state = 'enabled'; 57 let _instance: IPlugin.IPluginInstance; 58 try { 59 // eslint-disable-next-line no-new-func 60 _instance = Function(` 61 'use strict'; 62 try { 63 return ${funcCode}; 64 } catch(e) { 65 return null; 66 } 67 `)()({CryptoJs, axios, dayjs}); 68 this.checkValid(_instance); 69 } catch (e: any) { 70 this.state = 'error'; 71 this.stateCode = PluginStateCode.CannotParse; 72 if (e?.stateCode) { 73 this.stateCode = e.stateCode; 74 } 75 errorLog(`${pluginPath}插件无法解析 `, { 76 stateCode: this.stateCode, 77 message: e?.message, 78 stack: e?.stack, 79 }); 80 _instance = e?.instance ?? { 81 _path: '', 82 platform: '', 83 appVersion: '', 84 async getMusicTrack() { 85 return null; 86 }, 87 async search() { 88 return {}; 89 }, 90 async getAlbumInfo() { 91 return null; 92 }, 93 }; 94 } 95 this.instance = _instance; 96 this.path = pluginPath; 97 this.name = _instance.platform; 98 if (this.instance.platform === '') { 99 this.hash = ''; 100 } else { 101 this.hash = sha256(funcCode).toString(); 102 } 103 104 // 放在最后 105 this.methods = new PluginMethods(this); 106 } 107 108 private checkValid(_instance: IPlugin.IPluginInstance) { 109 /** 版本号校验 */ 110 if ( 111 _instance.appVersion && 112 !satisfies(DeviceInfo.getVersion(), _instance.appVersion) 113 ) { 114 throw { 115 instance: _instance, 116 stateCode: PluginStateCode.VersionNotMatch, 117 }; 118 } 119 return true; 120 } 121} 122 123/** 有缓存等信息 */ 124class PluginMethods implements IPlugin.IPluginInstanceMethods { 125 private plugin; 126 constructor(plugin: Plugin) { 127 this.plugin = plugin; 128 } 129 /** 搜索 */ 130 async search<T extends ICommon.SupportMediaType>( 131 query: string, 132 page: number, 133 type: T, 134 ): Promise<IPlugin.ISearchResult<T>> { 135 if (!this.plugin.instance.search) { 136 return { 137 isEnd: true, 138 data: [], 139 }; 140 } 141 142 const result = 143 (await this.plugin.instance.search(query, page, type)) ?? {}; 144 if (Array.isArray(result.data)) { 145 result.data.forEach(_ => { 146 resetMediaItem(_, this.plugin.name); 147 }); 148 return { 149 isEnd: result.isEnd ?? true, 150 data: result.data, 151 }; 152 } 153 return { 154 isEnd: true, 155 data: [], 156 }; 157 } 158 159 /** 获取真实源 */ 160 async getMusicTrack( 161 musicItem: IMusic.IMusicItemBase, 162 retryCount = 1, 163 ): Promise<IPlugin.IMusicTrackResult> { 164 // 1. 本地搜索 其实直接读mediameta就好了 165 const localPath = 166 musicItem?.[internalSymbolKey]?.localPath ?? 167 Download.getDownloaded(musicItem)?.[internalSymbolKey]?.localPath; 168 if (localPath && (await exists(localPath))) { 169 trace('播放', '本地播放'); 170 return { 171 url: localPath, 172 }; 173 } 174 // 2. 缓存播放 175 const mediaCache = Cache.get(musicItem); 176 if (mediaCache && mediaCache?.url) { 177 trace('播放', '缓存播放'); 178 return { 179 url: mediaCache.url, 180 headers: mediaCache.headers, 181 userAgent: 182 mediaCache.userAgent ?? mediaCache.headers?.['user-agent'], 183 }; 184 } 185 // 3. 插件解析 186 if (!this.plugin.instance.getMusicTrack) { 187 return {url: musicItem.url}; 188 } 189 try { 190 const {url, headers, cache} = 191 (await this.plugin.instance.getMusicTrack(musicItem)) ?? {}; 192 if (!url) { 193 throw new Error(); 194 } 195 trace('播放', '插件播放'); 196 const result = { 197 url, 198 headers, 199 userAgent: headers?.['user-agent'], 200 }; 201 202 if (cache !== false) { 203 Cache.update(musicItem, result); 204 } 205 return result; 206 } catch (e: any) { 207 if (retryCount > 0) { 208 await delay(150); 209 return this.getMusicTrack(musicItem, --retryCount); 210 } 211 errorLog('获取真实源失败', e?.message); 212 throw e; 213 } 214 } 215 216 /** 获取音乐详情 */ 217 async getMusicInfo( 218 musicItem: ICommon.IMediaBase, 219 ): Promise<IMusic.IMusicItem | null> { 220 if (!this.plugin.instance.getMusicInfo) { 221 return musicItem as IMusic.IMusicItem; 222 } 223 return ( 224 this.plugin.instance.getMusicInfo( 225 resetMediaItem(musicItem, undefined, true), 226 ) ?? musicItem 227 ); 228 } 229 230 /** 获取歌词 */ 231 async getLyric( 232 musicItem: IMusic.IMusicItemBase, 233 from?: IMusic.IMusicItemBase, 234 ): Promise<ILyric.ILyricSource | null> { 235 // 1.额外存储的meta信息 236 const meta = MediaMeta.get(musicItem); 237 if (meta && meta.associatedLrc) { 238 // 有关联歌词 239 if ( 240 isSameMediaItem(musicItem, from) || 241 isSameMediaItem(meta.associatedLrc, musicItem) 242 ) { 243 // 形成环路,断开当前的环 244 await MediaMeta.update(musicItem, { 245 associatedLrc: undefined, 246 }); 247 // 无歌词 248 return null; 249 } 250 // 获取关联歌词 251 const result = await this.getLyric( 252 meta.associatedLrc, 253 from ?? musicItem, 254 ); 255 if (result) { 256 // 如果有关联歌词,就返回关联歌词,深度优先 257 return result; 258 } 259 } 260 const cache = Cache.get(musicItem); 261 let rawLrc = meta?.rawLrc || musicItem.rawLrc || cache?.rawLrc; 262 let lrcUrl = meta?.lrc || musicItem.lrc || cache?.lrc; 263 // 如果存在文本 264 if (rawLrc) { 265 return { 266 rawLrc, 267 lrc: lrcUrl, 268 }; 269 } 270 // 2.本地缓存 271 const localLrc = 272 meta?.[internalSerialzeKey]?.local?.localLrc || 273 cache?.[internalSerialzeKey]?.local?.localLrc; 274 if (localLrc && (await exists(localLrc))) { 275 rawLrc = await readFile(localLrc, 'utf8'); 276 return { 277 rawLrc, 278 lrc: lrcUrl, 279 }; 280 } 281 // 3.优先使用url 282 if (lrcUrl) { 283 try { 284 // 需要超时时间 axios timeout 但是没生效 285 rawLrc = (await axios.get(lrcUrl, {timeout: 1500})).data; 286 return { 287 rawLrc, 288 lrc: lrcUrl, 289 }; 290 } catch { 291 lrcUrl = undefined; 292 } 293 } 294 // 4. 如果地址失效 295 if (!lrcUrl) { 296 // 插件获得url 297 try { 298 const lrcSource = await this.plugin.instance?.getLyric?.( 299 resetMediaItem(musicItem, undefined, true), 300 ); 301 rawLrc = lrcSource?.rawLrc; 302 lrcUrl = lrcSource?.lrc; 303 } catch (e: any) { 304 trace('插件获取歌词失败', e?.message, 'error'); 305 } 306 } 307 // 5. 最后一次请求 308 if (rawLrc || lrcUrl) { 309 const filename = `${pathConst.lrcCachePath}${nanoid()}.lrc`; 310 if (lrcUrl) { 311 try { 312 rawLrc = (await axios.get(lrcUrl, {timeout: 1500})).data; 313 } catch {} 314 } 315 if (rawLrc) { 316 await writeFile(filename, rawLrc, 'utf8'); 317 // 写入缓存 318 Cache.update(musicItem, [ 319 [`${internalSerialzeKey}.local.localLrc`, filename], 320 ]); 321 // 如果有meta 322 if (meta) { 323 MediaMeta.update(musicItem, [ 324 [`${internalSerialzeKey}.local.localLrc`, filename], 325 ]); 326 } 327 return { 328 rawLrc, 329 lrc: lrcUrl, 330 }; 331 } 332 } 333 334 return null; 335 } 336 337 /** 获取歌词文本 */ 338 async getLyricText( 339 musicItem: IMusic.IMusicItem, 340 ): Promise<string | undefined> { 341 return (await this.getLyric(musicItem))?.rawLrc; 342 } 343 344 /** 获取专辑信息 */ 345 async getAlbumInfo( 346 albumItem: IAlbum.IAlbumItemBase, 347 ): Promise<IAlbum.IAlbumItem | null> { 348 if (!this.plugin.instance.getAlbumInfo) { 349 return {...albumItem, musicList: []}; 350 } 351 try { 352 const result = await this.plugin.instance.getAlbumInfo( 353 resetMediaItem(albumItem, undefined, true), 354 ); 355 if (!result) { 356 throw new Error(); 357 } 358 result?.musicList?.forEach(_ => { 359 resetMediaItem(_, this.plugin.name); 360 }); 361 362 return {...albumItem, ...result}; 363 } catch { 364 return {...albumItem, musicList: []}; 365 } 366 } 367 368 /** 查询作者信息 */ 369 async queryArtistWorks<T extends IArtist.ArtistMediaType>( 370 artistItem: IArtist.IArtistItem, 371 page: number, 372 type: T, 373 ): Promise<IPlugin.ISearchResult<T>> { 374 if (!this.plugin.instance.queryArtistWorks) { 375 return { 376 isEnd: true, 377 data: [], 378 }; 379 } 380 try { 381 const result = await this.plugin.instance.queryArtistWorks( 382 artistItem, 383 page, 384 type, 385 ); 386 if (!result.data) { 387 return { 388 isEnd: true, 389 data: [], 390 }; 391 } 392 result.data?.forEach(_ => resetMediaItem(_, this.plugin.name)); 393 return { 394 isEnd: result.isEnd ?? true, 395 data: result.data, 396 }; 397 } catch (e) { 398 throw e; 399 } 400 } 401 402 /** 导入歌单 */ 403 async importMusicSheet(urlLike: string): Promise<IMusic.IMusicItem[]> { 404 try { 405 const result = 406 (await this.plugin.instance?.importMusicSheet?.(urlLike)) ?? []; 407 result.forEach(_ => resetMediaItem(_, this.plugin.name)); 408 return result; 409 } catch { 410 return []; 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