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 bigInt from 'big-integer'; 13import qs from 'qs'; 14import * as webdav from 'webdav'; 15import {InteractionManager, ToastAndroid} from 'react-native'; 16import pathConst from '@/constants/pathConst'; 17import {compare, satisfies} from 'compare-versions'; 18import DeviceInfo from 'react-native-device-info'; 19import StateMapper from '@/utils/stateMapper'; 20import MediaExtra from './mediaExtra'; 21import {nanoid} from 'nanoid'; 22import {devLog, errorLog, trace} from '../utils/log'; 23import { 24 getInternalData, 25 InternalDataType, 26 isSameMediaItem, 27 resetMediaItem, 28} from '@/utils/mediaItem'; 29import { 30 CacheControl, 31 emptyFunction, 32 internalSerializeKey, 33 localPluginHash, 34 localPluginPlatform, 35} from '@/constants/commonConst'; 36import delay from '@/utils/delay'; 37import * as cheerio from 'cheerio'; 38import CookieManager from '@react-native-cookies/cookies'; 39import he from 'he'; 40import Network from './network'; 41import LocalMusicSheet from './localMusicSheet'; 42import {getInfoAsync} from 'expo-file-system'; 43import Mp3Util from '@/native/mp3Util'; 44import {PluginMeta} from './pluginMeta'; 45import {useEffect, useState} from 'react'; 46import {addFileScheme, getFileName} from '@/utils/fileUtils'; 47import {URL} from 'react-native-url-polyfill'; 48import Base64 from '@/utils/base64'; 49import MediaCache from './mediaCache'; 50import {produce} from 'immer'; 51import objectPath from 'object-path'; 52 53axios.defaults.timeout = 2000; 54 55const sha256 = CryptoJs.SHA256; 56 57export enum PluginStateCode { 58 /** 版本不匹配 */ 59 VersionNotMatch = 'VERSION NOT MATCH', 60 /** 无法解析 */ 61 CannotParse = 'CANNOT PARSE', 62} 63 64const packages: Record<string, any> = { 65 cheerio, 66 'crypto-js': CryptoJs, 67 axios, 68 dayjs, 69 'big-integer': bigInt, 70 qs, 71 he, 72 '@react-native-cookies/cookies': CookieManager, 73 webdav, 74}; 75 76const _require = (packageName: string) => { 77 let pkg = packages[packageName]; 78 pkg.default = pkg; 79 return pkg; 80}; 81 82const _consoleBind = function ( 83 method: 'log' | 'error' | 'info' | 'warn', 84 ...args: any 85) { 86 const fn = console[method]; 87 if (fn) { 88 fn(...args); 89 devLog(method, ...args); 90 } 91}; 92 93const _console = { 94 log: _consoleBind.bind(null, 'log'), 95 warn: _consoleBind.bind(null, 'warn'), 96 info: _consoleBind.bind(null, 'info'), 97 error: _consoleBind.bind(null, 'error'), 98}; 99 100function formatAuthUrl(url: string) { 101 const urlObj = new URL(url); 102 103 try { 104 if (urlObj.username && urlObj.password) { 105 const auth = `Basic ${Base64.btoa( 106 `${decodeURIComponent(urlObj.username)}:${decodeURIComponent( 107 urlObj.password, 108 )}`, 109 )}`; 110 urlObj.username = ''; 111 urlObj.password = ''; 112 113 return { 114 url: urlObj.toString(), 115 auth, 116 }; 117 } 118 } catch (e) { 119 return { 120 url, 121 }; 122 } 123 return { 124 url, 125 }; 126} 127 128//#region 插件类 129export class Plugin { 130 /** 插件名 */ 131 public name: string; 132 /** 插件的hash,作为唯一id */ 133 public hash: string; 134 /** 插件状态:激活、关闭、错误 */ 135 public state: 'enabled' | 'disabled' | 'error'; 136 /** 插件状态信息 */ 137 public stateCode?: PluginStateCode; 138 /** 插件的实例 */ 139 public instance: IPlugin.IPluginInstance; 140 /** 插件路径 */ 141 public path: string; 142 /** 插件方法 */ 143 public methods: PluginMethods; 144 145 constructor( 146 funcCode: string | (() => IPlugin.IPluginInstance), 147 pluginPath: string, 148 ) { 149 this.state = 'enabled'; 150 let _instance: IPlugin.IPluginInstance; 151 const _module: any = {exports: {}}; 152 try { 153 if (typeof funcCode === 'string') { 154 // 插件的环境变量 155 const env = { 156 getUserVariables: () => { 157 return ( 158 PluginMeta.getPluginMeta(this)?.userVariables ?? {} 159 ); 160 }, 161 os: 'android', 162 }; 163 164 // eslint-disable-next-line no-new-func 165 _instance = Function(` 166 'use strict'; 167 return function(require, __musicfree_require, module, exports, console, env, URL) { 168 ${funcCode} 169 } 170 `)()( 171 _require, 172 _require, 173 _module, 174 _module.exports, 175 _console, 176 env, 177 URL, 178 ); 179 if (_module.exports.default) { 180 _instance = _module.exports 181 .default as IPlugin.IPluginInstance; 182 } else { 183 _instance = _module.exports as IPlugin.IPluginInstance; 184 } 185 } else { 186 _instance = funcCode(); 187 } 188 // 插件初始化后的一些操作 189 if (Array.isArray(_instance.userVariables)) { 190 _instance.userVariables = _instance.userVariables.filter( 191 it => it?.key, 192 ); 193 } 194 this.checkValid(_instance); 195 } catch (e: any) { 196 console.log(e); 197 this.state = 'error'; 198 this.stateCode = PluginStateCode.CannotParse; 199 if (e?.stateCode) { 200 this.stateCode = e.stateCode; 201 } 202 errorLog(`${pluginPath}插件无法解析 `, { 203 stateCode: this.stateCode, 204 message: e?.message, 205 stack: e?.stack, 206 }); 207 _instance = e?.instance ?? { 208 _path: '', 209 platform: '', 210 appVersion: '', 211 async getMediaSource() { 212 return null; 213 }, 214 async search() { 215 return {}; 216 }, 217 async getAlbumInfo() { 218 return null; 219 }, 220 }; 221 } 222 this.instance = _instance; 223 this.path = pluginPath; 224 this.name = _instance.platform; 225 if ( 226 this.instance.platform === '' || 227 this.instance.platform === undefined 228 ) { 229 this.hash = ''; 230 } else { 231 if (typeof funcCode === 'string') { 232 this.hash = sha256(funcCode).toString(); 233 } else { 234 this.hash = sha256(funcCode.toString()).toString(); 235 } 236 } 237 238 // 放在最后 239 this.methods = new PluginMethods(this); 240 } 241 242 private checkValid(_instance: IPlugin.IPluginInstance) { 243 /** 版本号校验 */ 244 if ( 245 _instance.appVersion && 246 !satisfies(DeviceInfo.getVersion(), _instance.appVersion) 247 ) { 248 throw { 249 instance: _instance, 250 stateCode: PluginStateCode.VersionNotMatch, 251 }; 252 } 253 return true; 254 } 255} 256//#endregion 257 258//#region 基于插件类封装的方法,供给APP侧直接调用 259/** 有缓存等信息 */ 260class PluginMethods implements IPlugin.IPluginInstanceMethods { 261 private plugin; 262 constructor(plugin: Plugin) { 263 this.plugin = plugin; 264 } 265 /** 搜索 */ 266 async search<T extends ICommon.SupportMediaType>( 267 query: string, 268 page: number, 269 type: T, 270 ): Promise<IPlugin.ISearchResult<T>> { 271 if (!this.plugin.instance.search) { 272 return { 273 isEnd: true, 274 data: [], 275 }; 276 } 277 278 const result = 279 (await this.plugin.instance.search(query, page, type)) ?? {}; 280 if (Array.isArray(result.data)) { 281 result.data.forEach(_ => { 282 resetMediaItem(_, this.plugin.name); 283 }); 284 return { 285 isEnd: result.isEnd ?? true, 286 data: result.data, 287 }; 288 } 289 return { 290 isEnd: true, 291 data: [], 292 }; 293 } 294 295 /** 获取真实源 */ 296 async getMediaSource( 297 musicItem: IMusic.IMusicItemBase, 298 quality: IMusic.IQualityKey = 'standard', 299 retryCount = 1, 300 notUpdateCache = false, 301 ): Promise<IPlugin.IMediaSourceResult | null> { 302 // 1. 本地搜索 其实直接读mediameta就好了 303 const mediaExtra = MediaExtra.get(musicItem); 304 const localPath = 305 mediaExtra?.localPath || 306 getInternalData<string>(musicItem, InternalDataType.LOCALPATH) || 307 getInternalData<string>( 308 LocalMusicSheet.isLocalMusic(musicItem), 309 InternalDataType.LOCALPATH, 310 ); 311 if (localPath && (await getInfoAsync(localPath)).exists) { 312 trace('本地播放', localPath); 313 if (mediaExtra && mediaExtra.localPath !== localPath) { 314 // 修正一下本地数据 315 MediaExtra.update(musicItem, { 316 localPath, 317 }); 318 } 319 return { 320 url: addFileScheme(localPath), 321 }; 322 } else if (mediaExtra?.localPath) { 323 MediaExtra.update(musicItem, { 324 localPath: undefined, 325 }); 326 } 327 328 if (musicItem.platform === localPluginPlatform) { 329 throw new Error('本地音乐不存在'); 330 } 331 // 2. 缓存播放 332 const mediaCache = MediaCache.getMediaCache( 333 musicItem, 334 ) as IMusic.IMusicItem | null; 335 const pluginCacheControl = 336 this.plugin.instance.cacheControl ?? 'no-cache'; 337 if ( 338 mediaCache && 339 mediaCache?.source?.[quality]?.url && 340 (pluginCacheControl === CacheControl.Cache || 341 (pluginCacheControl === CacheControl.NoCache && 342 Network.isOffline())) 343 ) { 344 trace('播放', '缓存播放'); 345 const qualityInfo = mediaCache.source[quality]; 346 return { 347 url: qualityInfo!.url, 348 headers: mediaCache.headers, 349 userAgent: 350 mediaCache.userAgent ?? mediaCache.headers?.['user-agent'], 351 }; 352 } 353 // 3. 插件解析 354 if (!this.plugin.instance.getMediaSource) { 355 const {url, auth} = formatAuthUrl( 356 musicItem?.qualities?.[quality]?.url ?? musicItem.url, 357 ); 358 return { 359 url: url, 360 headers: auth 361 ? { 362 Authorization: auth, 363 } 364 : undefined, 365 }; 366 } 367 try { 368 const {url, headers} = (await this.plugin.instance.getMediaSource( 369 musicItem, 370 quality, 371 )) ?? {url: musicItem?.qualities?.[quality]?.url}; 372 if (!url) { 373 throw new Error('NOT RETRY'); 374 } 375 trace('播放', '插件播放'); 376 const result = { 377 url, 378 headers, 379 userAgent: headers?.['user-agent'], 380 } as IPlugin.IMediaSourceResult; 381 const authFormattedResult = formatAuthUrl(result.url!); 382 if (authFormattedResult.auth) { 383 result.url = authFormattedResult.url; 384 result.headers = { 385 ...(result.headers ?? {}), 386 Authorization: authFormattedResult.auth, 387 }; 388 } 389 390 if ( 391 pluginCacheControl !== CacheControl.NoStore && 392 !notUpdateCache 393 ) { 394 // 更新缓存 395 const cacheSource = { 396 headers: result.headers, 397 userAgent: result.userAgent, 398 url, 399 }; 400 let realMusicItem = { 401 ...musicItem, 402 ...(mediaCache || {}), 403 }; 404 realMusicItem.source = { 405 ...(realMusicItem.source || {}), 406 [quality]: cacheSource, 407 }; 408 409 MediaCache.setMediaCache(realMusicItem); 410 } 411 return result; 412 } catch (e: any) { 413 if (retryCount > 0 && e?.message !== 'NOT RETRY') { 414 await delay(150); 415 return this.getMediaSource(musicItem, quality, --retryCount); 416 } 417 errorLog('获取真实源失败', e?.message); 418 devLog('error', '获取真实源失败', e, e?.message); 419 return null; 420 } 421 } 422 423 /** 获取音乐详情 */ 424 async getMusicInfo( 425 musicItem: ICommon.IMediaBase, 426 ): Promise<Partial<IMusic.IMusicItem> | null> { 427 if (!this.plugin.instance.getMusicInfo) { 428 return null; 429 } 430 try { 431 return ( 432 this.plugin.instance.getMusicInfo( 433 resetMediaItem(musicItem, undefined, true), 434 ) ?? null 435 ); 436 } catch (e: any) { 437 devLog('error', '获取音乐详情失败', e, e?.message); 438 return null; 439 } 440 } 441 442 /** 443 * 444 * getLyric(musicItem) => { 445 * lyric: string; 446 * trans: string; 447 * } 448 * 449 */ 450 /** 获取歌词 */ 451 async getLyric( 452 originalMusicItem: IMusic.IMusicItemBase, 453 ): Promise<ILyric.ILyricSource | null> { 454 // 1.额外存储的meta信息(关联歌词) 455 const meta = MediaExtra.get(originalMusicItem); 456 let musicItem: IMusic.IMusicItem; 457 if (meta && meta.associatedLrc) { 458 musicItem = meta.associatedLrc as IMusic.IMusicItem; 459 } else { 460 musicItem = originalMusicItem as IMusic.IMusicItem; 461 } 462 463 const musicItemCache = MediaCache.getMediaCache( 464 musicItem, 465 ) as IMusic.IMusicItemCache | null; 466 467 /** 原始歌词文本 */ 468 let rawLrc: string | null = musicItem.rawLrc || null; 469 let translation: string | null = null; 470 471 // 2. 缓存歌词 / 对象上本身的歌词 472 if (musicItemCache?.lyric) { 473 // 缓存的远程结果 474 let cacheLyric: ILyric.ILyricSource | null = 475 musicItemCache.lyric || null; 476 // 缓存的本地结果 477 let localLyric: ILyric.ILyricSource | null = 478 musicItemCache.$localLyric || null; 479 480 // 优先用缓存的结果 481 if (cacheLyric.rawLrc || cacheLyric.translation) { 482 return { 483 rawLrc: cacheLyric.rawLrc, 484 translation: cacheLyric.translation, 485 }; 486 } 487 488 // 本地其实是缓存的路径 489 if (localLyric) { 490 let needRefetch = false; 491 if (localLyric.rawLrc && (await exists(localLyric.rawLrc))) { 492 rawLrc = await readFile(localLyric.rawLrc, 'utf8'); 493 } else if (localLyric.rawLrc) { 494 needRefetch = true; 495 } 496 if ( 497 localLyric.translation && 498 (await exists(localLyric.translation)) 499 ) { 500 translation = await readFile( 501 localLyric.translation, 502 'utf8', 503 ); 504 } else if (localLyric.translation) { 505 needRefetch = true; 506 } 507 508 if (!needRefetch && (rawLrc || translation)) { 509 return { 510 rawLrc: rawLrc || undefined, 511 translation: translation || undefined, 512 }; 513 } 514 } 515 } 516 517 // 3. 无缓存歌词/无自带歌词/无本地歌词 518 let lrcSource: ILyric.ILyricSource | null; 519 if (isSameMediaItem(originalMusicItem, musicItem)) { 520 lrcSource = 521 (await this.plugin.instance 522 ?.getLyric?.(resetMediaItem(musicItem, undefined, true)) 523 ?.catch(() => null)) || null; 524 } else { 525 lrcSource = 526 (await PluginManager.getByMedia(musicItem) 527 ?.instance?.getLyric?.( 528 resetMediaItem(musicItem, undefined, true), 529 ) 530 ?.catch(() => null)) || null; 531 } 532 533 if (lrcSource) { 534 rawLrc = lrcSource?.rawLrc || rawLrc; 535 translation = lrcSource?.translation || null; 536 537 const deprecatedLrcUrl = lrcSource?.lrc || musicItem.lrc; 538 539 // 本地的文件名 540 let filename: string | undefined = `${ 541 pathConst.lrcCachePath 542 }${nanoid()}.lrc`; 543 let filenameTrans: string | undefined = `${ 544 pathConst.lrcCachePath 545 }${nanoid()}.lrc`; 546 547 // 旧版本兼容 548 if (!(rawLrc || translation)) { 549 if (deprecatedLrcUrl) { 550 rawLrc = ( 551 await axios 552 .get(deprecatedLrcUrl, {timeout: 3000}) 553 .catch(() => null) 554 )?.data; 555 } else if (musicItem.rawLrc) { 556 rawLrc = musicItem.rawLrc; 557 } 558 } 559 560 if (rawLrc) { 561 await writeFile(filename, rawLrc, 'utf8'); 562 } else { 563 filename = undefined; 564 } 565 if (translation) { 566 await writeFile(filenameTrans, translation, 'utf8'); 567 } else { 568 filenameTrans = undefined; 569 } 570 571 if (rawLrc || translation) { 572 MediaCache.setMediaCache( 573 produce(musicItemCache || musicItem, draft => { 574 musicItemCache?.$localLyric?.rawLrc; 575 objectPath.set(draft, '$localLyric.rawLrc', filename); 576 objectPath.set( 577 draft, 578 '$localLyric.translation', 579 filenameTrans, 580 ); 581 return draft; 582 }), 583 ); 584 return { 585 rawLrc: rawLrc || undefined, 586 translation: translation || undefined, 587 }; 588 } 589 } 590 591 // 6. 如果是本地文件 592 const isDownloaded = LocalMusicSheet.isLocalMusic(originalMusicItem); 593 if ( 594 originalMusicItem.platform !== localPluginPlatform && 595 isDownloaded 596 ) { 597 const res = await localFilePlugin.instance!.getLyric!(isDownloaded); 598 599 console.log('本地文件歌词'); 600 601 if (res) { 602 return res; 603 } 604 } 605 devLog('warn', '无歌词'); 606 607 return null; 608 } 609 610 /** 获取歌词文本 */ 611 async getLyricText( 612 musicItem: IMusic.IMusicItem, 613 ): Promise<string | undefined> { 614 return (await this.getLyric(musicItem))?.rawLrc; 615 } 616 617 /** 获取专辑信息 */ 618 async getAlbumInfo( 619 albumItem: IAlbum.IAlbumItemBase, 620 page: number = 1, 621 ): Promise<IPlugin.IAlbumInfoResult | null> { 622 if (!this.plugin.instance.getAlbumInfo) { 623 return { 624 albumItem, 625 musicList: (albumItem?.musicList ?? []).map( 626 resetMediaItem, 627 this.plugin.name, 628 true, 629 ), 630 isEnd: true, 631 }; 632 } 633 try { 634 const result = await this.plugin.instance.getAlbumInfo( 635 resetMediaItem(albumItem, undefined, true), 636 page, 637 ); 638 if (!result) { 639 throw new Error(); 640 } 641 result?.musicList?.forEach(_ => { 642 resetMediaItem(_, this.plugin.name); 643 _.album = albumItem.title; 644 }); 645 646 if (page <= 1) { 647 // 合并信息 648 return { 649 albumItem: {...albumItem, ...(result?.albumItem ?? {})}, 650 isEnd: result.isEnd === false ? false : true, 651 musicList: result.musicList, 652 }; 653 } else { 654 return { 655 isEnd: result.isEnd === false ? false : true, 656 musicList: result.musicList, 657 }; 658 } 659 } catch (e: any) { 660 trace('获取专辑信息失败', e?.message); 661 devLog('error', '获取专辑信息失败', e, e?.message); 662 663 return null; 664 } 665 } 666 667 /** 获取歌单信息 */ 668 async getMusicSheetInfo( 669 sheetItem: IMusic.IMusicSheetItem, 670 page: number = 1, 671 ): Promise<IPlugin.ISheetInfoResult | null> { 672 if (!this.plugin.instance.getMusicSheetInfo) { 673 return { 674 sheetItem, 675 musicList: sheetItem?.musicList ?? [], 676 isEnd: true, 677 }; 678 } 679 try { 680 const result = await this.plugin.instance?.getMusicSheetInfo?.( 681 resetMediaItem(sheetItem, undefined, true), 682 page, 683 ); 684 if (!result) { 685 throw new Error(); 686 } 687 result?.musicList?.forEach(_ => { 688 resetMediaItem(_, this.plugin.name); 689 }); 690 691 if (page <= 1) { 692 // 合并信息 693 return { 694 sheetItem: {...sheetItem, ...(result?.sheetItem ?? {})}, 695 isEnd: result.isEnd === false ? false : true, 696 musicList: result.musicList, 697 }; 698 } else { 699 return { 700 isEnd: result.isEnd === false ? false : true, 701 musicList: result.musicList, 702 }; 703 } 704 } catch (e: any) { 705 trace('获取歌单信息失败', e, e?.message); 706 devLog('error', '获取歌单信息失败', e, e?.message); 707 708 return null; 709 } 710 } 711 712 /** 查询作者信息 */ 713 async getArtistWorks<T extends IArtist.ArtistMediaType>( 714 artistItem: IArtist.IArtistItem, 715 page: number, 716 type: T, 717 ): Promise<IPlugin.ISearchResult<T>> { 718 if (!this.plugin.instance.getArtistWorks) { 719 return { 720 isEnd: true, 721 data: [], 722 }; 723 } 724 try { 725 const result = await this.plugin.instance.getArtistWorks( 726 artistItem, 727 page, 728 type, 729 ); 730 if (!result.data) { 731 return { 732 isEnd: true, 733 data: [], 734 }; 735 } 736 result.data?.forEach(_ => resetMediaItem(_, this.plugin.name)); 737 return { 738 isEnd: result.isEnd ?? true, 739 data: result.data, 740 }; 741 } catch (e: any) { 742 trace('查询作者信息失败', e?.message); 743 devLog('error', '查询作者信息失败', e, e?.message); 744 745 throw e; 746 } 747 } 748 749 /** 导入歌单 */ 750 async importMusicSheet(urlLike: string): Promise<IMusic.IMusicItem[]> { 751 try { 752 const result = 753 (await this.plugin.instance?.importMusicSheet?.(urlLike)) ?? []; 754 result.forEach(_ => resetMediaItem(_, this.plugin.name)); 755 return result; 756 } catch (e: any) { 757 console.log(e); 758 devLog('error', '导入歌单失败', e, e?.message); 759 760 return []; 761 } 762 } 763 /** 导入单曲 */ 764 async importMusicItem(urlLike: string): Promise<IMusic.IMusicItem | null> { 765 try { 766 const result = await this.plugin.instance?.importMusicItem?.( 767 urlLike, 768 ); 769 if (!result) { 770 throw new Error(); 771 } 772 resetMediaItem(result, this.plugin.name); 773 return result; 774 } catch (e: any) { 775 devLog('error', '导入单曲失败', e, e?.message); 776 777 return null; 778 } 779 } 780 /** 获取榜单 */ 781 async getTopLists(): Promise<IMusic.IMusicSheetGroupItem[]> { 782 try { 783 const result = await this.plugin.instance?.getTopLists?.(); 784 if (!result) { 785 throw new Error(); 786 } 787 return result; 788 } catch (e: any) { 789 devLog('error', '获取榜单失败', e, e?.message); 790 return []; 791 } 792 } 793 /** 获取榜单详情 */ 794 async getTopListDetail( 795 topListItem: IMusic.IMusicSheetItemBase, 796 page: number, 797 ): Promise<IPlugin.ITopListInfoResult> { 798 try { 799 const result = await this.plugin.instance?.getTopListDetail?.( 800 topListItem, 801 page, 802 ); 803 if (!result) { 804 throw new Error(); 805 } 806 if (result.musicList) { 807 result.musicList.forEach(_ => 808 resetMediaItem(_, this.plugin.name), 809 ); 810 } 811 if (result.isEnd !== false) { 812 result.isEnd = true; 813 } 814 return result; 815 } catch (e: any) { 816 devLog('error', '获取榜单详情失败', e, e?.message); 817 return { 818 isEnd: true, 819 topListItem: topListItem as IMusic.IMusicSheetItem, 820 musicList: [], 821 }; 822 } 823 } 824 825 /** 获取推荐歌单的tag */ 826 async getRecommendSheetTags(): Promise<IPlugin.IGetRecommendSheetTagsResult> { 827 try { 828 const result = 829 await this.plugin.instance?.getRecommendSheetTags?.(); 830 if (!result) { 831 throw new Error(); 832 } 833 return result; 834 } catch (e: any) { 835 devLog('error', '获取推荐歌单失败', e, e?.message); 836 return { 837 data: [], 838 }; 839 } 840 } 841 /** 获取某个tag的推荐歌单 */ 842 async getRecommendSheetsByTag( 843 tagItem: ICommon.IUnique, 844 page?: number, 845 ): Promise<ICommon.PaginationResponse<IMusic.IMusicSheetItemBase>> { 846 try { 847 const result = 848 await this.plugin.instance?.getRecommendSheetsByTag?.( 849 tagItem, 850 page ?? 1, 851 ); 852 if (!result) { 853 throw new Error(); 854 } 855 if (result.isEnd !== false) { 856 result.isEnd = true; 857 } 858 if (!result.data) { 859 result.data = []; 860 } 861 result.data.forEach(item => resetMediaItem(item, this.plugin.name)); 862 863 return result; 864 } catch (e: any) { 865 devLog('error', '获取推荐歌单详情失败', e, e?.message); 866 return { 867 isEnd: true, 868 data: [], 869 }; 870 } 871 } 872} 873//#endregion 874 875let plugins: Array<Plugin> = []; 876const pluginStateMapper = new StateMapper(() => plugins); 877 878//#region 本地音乐插件 879/** 本地插件 */ 880const localFilePlugin = new Plugin(function () { 881 return { 882 platform: localPluginPlatform, 883 _path: '', 884 async getMusicInfo(musicBase) { 885 const localPath = getInternalData<string>( 886 musicBase, 887 InternalDataType.LOCALPATH, 888 ); 889 if (localPath) { 890 const coverImg = await Mp3Util.getMediaCoverImg(localPath); 891 return { 892 artwork: coverImg, 893 }; 894 } 895 return null; 896 }, 897 async getLyric(musicBase) { 898 const localPath = getInternalData<string>( 899 musicBase, 900 InternalDataType.LOCALPATH, 901 ); 902 let rawLrc: string | null = null; 903 if (localPath) { 904 // 读取内嵌歌词 905 try { 906 rawLrc = await Mp3Util.getLyric(localPath); 907 } catch (e) { 908 console.log('读取内嵌歌词失败', e); 909 } 910 if (!rawLrc) { 911 // 读取配置歌词 912 const lastDot = localPath.lastIndexOf('.'); 913 const lrcPath = localPath.slice(0, lastDot) + '.lrc'; 914 915 try { 916 if (await exists(lrcPath)) { 917 rawLrc = await readFile(lrcPath, 'utf8'); 918 } 919 } catch {} 920 } 921 } 922 923 return rawLrc 924 ? { 925 rawLrc, 926 } 927 : null; 928 }, 929 async importMusicItem(urlLike) { 930 let meta: any = {}; 931 try { 932 meta = await Mp3Util.getBasicMeta(urlLike); 933 } catch {} 934 const stat = await getInfoAsync(urlLike, { 935 md5: true, 936 }); 937 let id: string; 938 if (stat.exists) { 939 id = stat.md5 || nanoid(); 940 } else { 941 id = nanoid(); 942 } 943 return { 944 id: id, 945 platform: '本地', 946 title: meta?.title ?? getFileName(urlLike), 947 artist: meta?.artist ?? '未知歌手', 948 duration: parseInt(meta?.duration ?? '0', 10) / 1000, 949 album: meta?.album ?? '未知专辑', 950 artwork: '', 951 [internalSerializeKey]: { 952 localPath: urlLike, 953 }, 954 }; 955 }, 956 async getMediaSource(musicItem, quality) { 957 if (quality === 'standard') { 958 return { 959 url: addFileScheme(musicItem.$?.localPath || musicItem.url), 960 }; 961 } 962 return null; 963 }, 964 }; 965}, ''); 966localFilePlugin.hash = localPluginHash; 967 968//#endregion 969 970async function setup() { 971 const _plugins: Array<Plugin> = []; 972 try { 973 // 加载插件 974 const pluginsPaths = await readDir(pathConst.pluginPath); 975 for (let i = 0; i < pluginsPaths.length; ++i) { 976 const _pluginUrl = pluginsPaths[i]; 977 trace('初始化插件', _pluginUrl); 978 if ( 979 _pluginUrl.isFile() && 980 (_pluginUrl.name?.endsWith?.('.js') || 981 _pluginUrl.path?.endsWith?.('.js')) 982 ) { 983 const funcCode = await readFile(_pluginUrl.path, 'utf8'); 984 const plugin = new Plugin(funcCode, _pluginUrl.path); 985 const _pluginIndex = _plugins.findIndex( 986 p => p.hash === plugin.hash, 987 ); 988 if (_pluginIndex !== -1) { 989 // 重复插件,直接忽略 990 continue; 991 } 992 plugin.hash !== '' && _plugins.push(plugin); 993 } 994 } 995 996 plugins = _plugins; 997 /** 初始化meta信息 */ 998 await PluginMeta.setupMeta(plugins.map(_ => _.name)); 999 /** 查看一下是否有禁用的标记 */ 1000 const allMeta = PluginMeta.getPluginMetaAll() ?? {}; 1001 for (let plugin of plugins) { 1002 if (allMeta[plugin.name]?.enabled === false) { 1003 plugin.state = 'disabled'; 1004 } 1005 } 1006 pluginStateMapper.notify(); 1007 } catch (e: any) { 1008 ToastAndroid.show( 1009 `插件初始化失败:${e?.message ?? e}`, 1010 ToastAndroid.LONG, 1011 ); 1012 errorLog('插件初始化失败', e?.message); 1013 throw e; 1014 } 1015} 1016 1017interface IInstallPluginConfig { 1018 notCheckVersion?: boolean; 1019} 1020 1021// 安装插件 1022async function installPlugin( 1023 pluginPath: string, 1024 config?: IInstallPluginConfig, 1025) { 1026 // if (pluginPath.endsWith('.js')) { 1027 const funcCode = await readFile(pluginPath, 'utf8'); 1028 1029 if (funcCode) { 1030 const plugin = new Plugin(funcCode, pluginPath); 1031 const _pluginIndex = plugins.findIndex(p => p.hash === plugin.hash); 1032 if (_pluginIndex !== -1) { 1033 // 静默忽略 1034 return plugin; 1035 } 1036 const oldVersionPlugin = plugins.find(p => p.name === plugin.name); 1037 if (oldVersionPlugin && !config?.notCheckVersion) { 1038 if ( 1039 compare( 1040 oldVersionPlugin.instance.version ?? '', 1041 plugin.instance.version ?? '', 1042 '>', 1043 ) 1044 ) { 1045 throw new Error('已安装更新版本的插件'); 1046 } 1047 } 1048 1049 if (plugin.hash !== '') { 1050 const fn = nanoid(); 1051 if (oldVersionPlugin) { 1052 plugins = plugins.filter(_ => _.hash !== oldVersionPlugin.hash); 1053 try { 1054 await unlink(oldVersionPlugin.path); 1055 } catch {} 1056 } 1057 const _pluginPath = `${pathConst.pluginPath}${fn}.js`; 1058 await copyFile(pluginPath, _pluginPath); 1059 plugin.path = _pluginPath; 1060 plugins = plugins.concat(plugin); 1061 pluginStateMapper.notify(); 1062 return plugin; 1063 } 1064 throw new Error('插件无法解析!'); 1065 } 1066 throw new Error('插件无法识别!'); 1067} 1068 1069const reqHeaders = { 1070 'Cache-Control': 'no-cache', 1071 Pragma: 'no-cache', 1072 Expires: '0', 1073}; 1074 1075async function installPluginFromUrl( 1076 url: string, 1077 config?: IInstallPluginConfig, 1078) { 1079 try { 1080 const funcCode = ( 1081 await axios.get(url, { 1082 headers: reqHeaders, 1083 }) 1084 ).data; 1085 if (funcCode) { 1086 const plugin = new Plugin(funcCode, ''); 1087 const _pluginIndex = plugins.findIndex(p => p.hash === plugin.hash); 1088 if (_pluginIndex !== -1) { 1089 // 静默忽略 1090 return; 1091 } 1092 const oldVersionPlugin = plugins.find(p => p.name === plugin.name); 1093 if (oldVersionPlugin && !config?.notCheckVersion) { 1094 if ( 1095 compare( 1096 oldVersionPlugin.instance.version ?? '', 1097 plugin.instance.version ?? '', 1098 '>', 1099 ) 1100 ) { 1101 throw new Error('已安装更新版本的插件'); 1102 } 1103 } 1104 1105 if (plugin.hash !== '') { 1106 const fn = nanoid(); 1107 const _pluginPath = `${pathConst.pluginPath}${fn}.js`; 1108 await writeFile(_pluginPath, funcCode, 'utf8'); 1109 plugin.path = _pluginPath; 1110 plugins = plugins.concat(plugin); 1111 if (oldVersionPlugin) { 1112 plugins = plugins.filter( 1113 _ => _.hash !== oldVersionPlugin.hash, 1114 ); 1115 try { 1116 await unlink(oldVersionPlugin.path); 1117 } catch {} 1118 } 1119 pluginStateMapper.notify(); 1120 return; 1121 } 1122 throw new Error('插件无法解析!'); 1123 } 1124 } catch (e: any) { 1125 devLog('error', 'URL安装插件失败', e, e?.message); 1126 errorLog('URL安装插件失败', e); 1127 throw new Error(e?.message ?? ''); 1128 } 1129} 1130 1131/** 卸载插件 */ 1132async function uninstallPlugin(hash: string) { 1133 const targetIndex = plugins.findIndex(_ => _.hash === hash); 1134 if (targetIndex !== -1) { 1135 try { 1136 const pluginName = plugins[targetIndex].name; 1137 await unlink(plugins[targetIndex].path); 1138 plugins = plugins.filter(_ => _.hash !== hash); 1139 pluginStateMapper.notify(); 1140 // 防止其他重名 1141 if (plugins.every(_ => _.name !== pluginName)) { 1142 MediaExtra.removeAll(pluginName); 1143 } 1144 } catch {} 1145 } 1146} 1147 1148async function uninstallAllPlugins() { 1149 await Promise.all( 1150 plugins.map(async plugin => { 1151 try { 1152 const pluginName = plugin.name; 1153 await unlink(plugin.path); 1154 MediaExtra.removeAll(pluginName); 1155 } catch (e) {} 1156 }), 1157 ); 1158 plugins = []; 1159 pluginStateMapper.notify(); 1160 1161 /** 清除空余文件,异步做就可以了 */ 1162 readDir(pathConst.pluginPath) 1163 .then(fns => { 1164 fns.forEach(fn => { 1165 unlink(fn.path).catch(emptyFunction); 1166 }); 1167 }) 1168 .catch(emptyFunction); 1169} 1170 1171async function updatePlugin(plugin: Plugin) { 1172 const updateUrl = plugin.instance.srcUrl; 1173 if (!updateUrl) { 1174 throw new Error('没有更新源'); 1175 } 1176 try { 1177 await installPluginFromUrl(updateUrl); 1178 } catch (e: any) { 1179 if (e.message === '插件已安装') { 1180 throw new Error('当前已是最新版本'); 1181 } else { 1182 throw e; 1183 } 1184 } 1185} 1186 1187function getByMedia(mediaItem: ICommon.IMediaBase) { 1188 return getByName(mediaItem?.platform); 1189} 1190 1191function getByHash(hash: string) { 1192 return hash === localPluginHash 1193 ? localFilePlugin 1194 : plugins.find(_ => _.hash === hash); 1195} 1196 1197function getByName(name: string) { 1198 return name === localPluginPlatform 1199 ? localFilePlugin 1200 : plugins.find(_ => _.name === name); 1201} 1202 1203function getValidPlugins() { 1204 return plugins.filter(_ => _.state === 'enabled'); 1205} 1206 1207function getSearchablePlugins(supportedSearchType?: ICommon.SupportMediaType) { 1208 return plugins.filter( 1209 _ => 1210 _.state === 'enabled' && 1211 _.instance.search && 1212 (supportedSearchType && _.instance.supportedSearchType 1213 ? _.instance.supportedSearchType.includes(supportedSearchType) 1214 : true), 1215 ); 1216} 1217 1218function getSortedSearchablePlugins( 1219 supportedSearchType?: ICommon.SupportMediaType, 1220) { 1221 return getSearchablePlugins(supportedSearchType).sort((a, b) => 1222 (PluginMeta.getPluginMeta(a).order ?? Infinity) - 1223 (PluginMeta.getPluginMeta(b).order ?? Infinity) < 1224 0 1225 ? -1 1226 : 1, 1227 ); 1228} 1229 1230function getTopListsablePlugins() { 1231 return plugins.filter(_ => _.state === 'enabled' && _.instance.getTopLists); 1232} 1233 1234function getSortedTopListsablePlugins() { 1235 return getTopListsablePlugins().sort((a, b) => 1236 (PluginMeta.getPluginMeta(a).order ?? Infinity) - 1237 (PluginMeta.getPluginMeta(b).order ?? Infinity) < 1238 0 1239 ? -1 1240 : 1, 1241 ); 1242} 1243 1244function getRecommendSheetablePlugins() { 1245 return plugins.filter( 1246 _ => _.state === 'enabled' && _.instance.getRecommendSheetsByTag, 1247 ); 1248} 1249 1250function getSortedRecommendSheetablePlugins() { 1251 return getRecommendSheetablePlugins().sort((a, b) => 1252 (PluginMeta.getPluginMeta(a).order ?? Infinity) - 1253 (PluginMeta.getPluginMeta(b).order ?? Infinity) < 1254 0 1255 ? -1 1256 : 1, 1257 ); 1258} 1259 1260function useSortedPlugins() { 1261 const _plugins = pluginStateMapper.useMappedState(); 1262 const _pluginMetaAll = PluginMeta.usePluginMetaAll(); 1263 1264 const [sortedPlugins, setSortedPlugins] = useState( 1265 [..._plugins].sort((a, b) => 1266 (_pluginMetaAll[a.name]?.order ?? Infinity) - 1267 (_pluginMetaAll[b.name]?.order ?? Infinity) < 1268 0 1269 ? -1 1270 : 1, 1271 ), 1272 ); 1273 1274 useEffect(() => { 1275 InteractionManager.runAfterInteractions(() => { 1276 setSortedPlugins( 1277 [..._plugins].sort((a, b) => 1278 (_pluginMetaAll[a.name]?.order ?? Infinity) - 1279 (_pluginMetaAll[b.name]?.order ?? Infinity) < 1280 0 1281 ? -1 1282 : 1, 1283 ), 1284 ); 1285 }); 1286 }, [_plugins, _pluginMetaAll]); 1287 1288 return sortedPlugins; 1289} 1290 1291async function setPluginEnabled(plugin: Plugin, enabled?: boolean) { 1292 const target = plugins.find(it => it.hash === plugin.hash); 1293 if (target) { 1294 target.state = enabled ? 'enabled' : 'disabled'; 1295 plugins = [...plugins]; 1296 pluginStateMapper.notify(); 1297 PluginMeta.setPluginMetaProp(plugin, 'enabled', enabled); 1298 } 1299} 1300 1301const PluginManager = { 1302 setup, 1303 installPlugin, 1304 installPluginFromUrl, 1305 updatePlugin, 1306 uninstallPlugin, 1307 getByMedia, 1308 getByHash, 1309 getByName, 1310 getValidPlugins, 1311 getSearchablePlugins, 1312 getSortedSearchablePlugins, 1313 getTopListsablePlugins, 1314 getSortedRecommendSheetablePlugins, 1315 getSortedTopListsablePlugins, 1316 usePlugins: pluginStateMapper.useMappedState, 1317 useSortedPlugins, 1318 uninstallAllPlugins, 1319 setPluginEnabled, 1320}; 1321 1322export default PluginManager; 1323