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