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