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