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 1021async function installPluginFromRawCode( 1022 funcCode: string, 1023 config?: IInstallPluginConfig, 1024) { 1025 if (funcCode) { 1026 const plugin = new Plugin(funcCode, ''); 1027 const _pluginIndex = plugins.findIndex(p => p.hash === plugin.hash); 1028 if (_pluginIndex !== -1) { 1029 // 静默忽略 1030 return plugin; 1031 } 1032 const oldVersionPlugin = plugins.find(p => p.name === plugin.name); 1033 if (oldVersionPlugin && !config?.notCheckVersion) { 1034 if ( 1035 compare( 1036 oldVersionPlugin.instance.version ?? '', 1037 plugin.instance.version ?? '', 1038 '>', 1039 ) 1040 ) { 1041 throw new Error('已安装更新版本的插件'); 1042 } 1043 } 1044 1045 if (plugin.hash !== '') { 1046 const fn = nanoid(); 1047 if (oldVersionPlugin) { 1048 plugins = plugins.filter(_ => _.hash !== oldVersionPlugin.hash); 1049 try { 1050 await unlink(oldVersionPlugin.path); 1051 } catch {} 1052 } 1053 const pluginPath = `${pathConst.pluginPath}${fn}.js`; 1054 await writeFile(pluginPath, funcCode, 'utf8'); 1055 plugin.path = pluginPath; 1056 plugins = plugins.concat(plugin); 1057 pluginStateMapper.notify(); 1058 return plugin; 1059 } 1060 throw new Error('插件无法解析!'); 1061 } 1062} 1063 1064// 安装插件 1065async function installPlugin( 1066 pluginPath: string, 1067 config?: IInstallPluginConfig, 1068) { 1069 // if (pluginPath.endsWith('.js')) { 1070 const funcCode = await readFile(pluginPath, 'utf8'); 1071 1072 if (funcCode) { 1073 const plugin = new Plugin(funcCode, pluginPath); 1074 const _pluginIndex = plugins.findIndex(p => p.hash === plugin.hash); 1075 if (_pluginIndex !== -1) { 1076 // 静默忽略 1077 return plugin; 1078 } 1079 const oldVersionPlugin = plugins.find(p => p.name === plugin.name); 1080 if (oldVersionPlugin && !config?.notCheckVersion) { 1081 if ( 1082 compare( 1083 oldVersionPlugin.instance.version ?? '', 1084 plugin.instance.version ?? '', 1085 '>', 1086 ) 1087 ) { 1088 throw new Error('已安装更新版本的插件'); 1089 } 1090 } 1091 1092 if (plugin.hash !== '') { 1093 const fn = nanoid(); 1094 if (oldVersionPlugin) { 1095 plugins = plugins.filter(_ => _.hash !== oldVersionPlugin.hash); 1096 try { 1097 await unlink(oldVersionPlugin.path); 1098 } catch {} 1099 } 1100 const _pluginPath = `${pathConst.pluginPath}${fn}.js`; 1101 await copyFile(pluginPath, _pluginPath); 1102 plugin.path = _pluginPath; 1103 plugins = plugins.concat(plugin); 1104 pluginStateMapper.notify(); 1105 return plugin; 1106 } 1107 throw new Error('插件无法解析!'); 1108 } 1109 throw new Error('插件无法识别!'); 1110} 1111 1112const reqHeaders = { 1113 'Cache-Control': 'no-cache', 1114 Pragma: 'no-cache', 1115 Expires: '0', 1116}; 1117 1118async function installPluginFromUrl( 1119 url: string, 1120 config?: IInstallPluginConfig, 1121) { 1122 try { 1123 const funcCode = ( 1124 await axios.get(url, { 1125 headers: reqHeaders, 1126 }) 1127 ).data; 1128 if (funcCode) { 1129 const plugin = new Plugin(funcCode, ''); 1130 const _pluginIndex = plugins.findIndex(p => p.hash === plugin.hash); 1131 if (_pluginIndex !== -1) { 1132 // 静默忽略 1133 return; 1134 } 1135 const oldVersionPlugin = plugins.find(p => p.name === plugin.name); 1136 if (oldVersionPlugin && !config?.notCheckVersion) { 1137 if ( 1138 compare( 1139 oldVersionPlugin.instance.version ?? '', 1140 plugin.instance.version ?? '', 1141 '>', 1142 ) 1143 ) { 1144 throw new Error('已安装更新版本的插件'); 1145 } 1146 } 1147 1148 if (plugin.hash !== '') { 1149 const fn = nanoid(); 1150 const _pluginPath = `${pathConst.pluginPath}${fn}.js`; 1151 await writeFile(_pluginPath, funcCode, 'utf8'); 1152 plugin.path = _pluginPath; 1153 plugins = plugins.concat(plugin); 1154 if (oldVersionPlugin) { 1155 plugins = plugins.filter( 1156 _ => _.hash !== oldVersionPlugin.hash, 1157 ); 1158 try { 1159 await unlink(oldVersionPlugin.path); 1160 } catch {} 1161 } 1162 pluginStateMapper.notify(); 1163 return; 1164 } 1165 throw new Error('插件无法解析!'); 1166 } 1167 } catch (e: any) { 1168 devLog('error', 'URL安装插件失败', e, e?.message); 1169 errorLog('URL安装插件失败', e); 1170 throw new Error(e?.message ?? ''); 1171 } 1172} 1173 1174/** 卸载插件 */ 1175async function uninstallPlugin(hash: string) { 1176 const targetIndex = plugins.findIndex(_ => _.hash === hash); 1177 if (targetIndex !== -1) { 1178 try { 1179 const pluginName = plugins[targetIndex].name; 1180 await unlink(plugins[targetIndex].path); 1181 plugins = plugins.filter(_ => _.hash !== hash); 1182 pluginStateMapper.notify(); 1183 // 防止其他重名 1184 if (plugins.every(_ => _.name !== pluginName)) { 1185 MediaExtra.removeAll(pluginName); 1186 } 1187 } catch {} 1188 } 1189} 1190 1191async function uninstallAllPlugins() { 1192 await Promise.all( 1193 plugins.map(async plugin => { 1194 try { 1195 const pluginName = plugin.name; 1196 await unlink(plugin.path); 1197 MediaExtra.removeAll(pluginName); 1198 } catch (e) {} 1199 }), 1200 ); 1201 plugins = []; 1202 pluginStateMapper.notify(); 1203 1204 /** 清除空余文件,异步做就可以了 */ 1205 readDir(pathConst.pluginPath) 1206 .then(fns => { 1207 fns.forEach(fn => { 1208 unlink(fn.path).catch(emptyFunction); 1209 }); 1210 }) 1211 .catch(emptyFunction); 1212} 1213 1214async function updatePlugin(plugin: Plugin) { 1215 const updateUrl = plugin.instance.srcUrl; 1216 if (!updateUrl) { 1217 throw new Error('没有更新源'); 1218 } 1219 try { 1220 await installPluginFromUrl(updateUrl); 1221 } catch (e: any) { 1222 if (e.message === '插件已安装') { 1223 throw new Error('当前已是最新版本'); 1224 } else { 1225 throw e; 1226 } 1227 } 1228} 1229 1230function getByMedia(mediaItem: ICommon.IMediaBase) { 1231 return getByName(mediaItem?.platform); 1232} 1233 1234function getByHash(hash: string) { 1235 return hash === localPluginHash 1236 ? localFilePlugin 1237 : plugins.find(_ => _.hash === hash); 1238} 1239 1240function getByName(name: string) { 1241 return name === localPluginPlatform 1242 ? localFilePlugin 1243 : plugins.find(_ => _.name === name); 1244} 1245 1246function getValidPlugins() { 1247 return plugins.filter(_ => _.state === 'enabled'); 1248} 1249 1250function getSearchablePlugins(supportedSearchType?: ICommon.SupportMediaType) { 1251 return plugins.filter( 1252 _ => 1253 _.state === 'enabled' && 1254 _.instance.search && 1255 (supportedSearchType && _.instance.supportedSearchType 1256 ? _.instance.supportedSearchType.includes(supportedSearchType) 1257 : true), 1258 ); 1259} 1260 1261function getSortedSearchablePlugins( 1262 supportedSearchType?: ICommon.SupportMediaType, 1263) { 1264 return getSearchablePlugins(supportedSearchType).sort((a, b) => 1265 (PluginMeta.getPluginMeta(a).order ?? Infinity) - 1266 (PluginMeta.getPluginMeta(b).order ?? Infinity) < 1267 0 1268 ? -1 1269 : 1, 1270 ); 1271} 1272 1273function getTopListsablePlugins() { 1274 return plugins.filter(_ => _.state === 'enabled' && _.instance.getTopLists); 1275} 1276 1277function getSortedTopListsablePlugins() { 1278 return getTopListsablePlugins().sort((a, b) => 1279 (PluginMeta.getPluginMeta(a).order ?? Infinity) - 1280 (PluginMeta.getPluginMeta(b).order ?? Infinity) < 1281 0 1282 ? -1 1283 : 1, 1284 ); 1285} 1286 1287function getRecommendSheetablePlugins() { 1288 return plugins.filter( 1289 _ => _.state === 'enabled' && _.instance.getRecommendSheetsByTag, 1290 ); 1291} 1292 1293function getSortedRecommendSheetablePlugins() { 1294 return getRecommendSheetablePlugins().sort((a, b) => 1295 (PluginMeta.getPluginMeta(a).order ?? Infinity) - 1296 (PluginMeta.getPluginMeta(b).order ?? Infinity) < 1297 0 1298 ? -1 1299 : 1, 1300 ); 1301} 1302 1303function useSortedPlugins() { 1304 const _plugins = pluginStateMapper.useMappedState(); 1305 const _pluginMetaAll = PluginMeta.usePluginMetaAll(); 1306 1307 const [sortedPlugins, setSortedPlugins] = useState( 1308 [..._plugins].sort((a, b) => 1309 (_pluginMetaAll[a.name]?.order ?? Infinity) - 1310 (_pluginMetaAll[b.name]?.order ?? Infinity) < 1311 0 1312 ? -1 1313 : 1, 1314 ), 1315 ); 1316 1317 useEffect(() => { 1318 InteractionManager.runAfterInteractions(() => { 1319 setSortedPlugins( 1320 [..._plugins].sort((a, b) => 1321 (_pluginMetaAll[a.name]?.order ?? Infinity) - 1322 (_pluginMetaAll[b.name]?.order ?? Infinity) < 1323 0 1324 ? -1 1325 : 1, 1326 ), 1327 ); 1328 }); 1329 }, [_plugins, _pluginMetaAll]); 1330 1331 return sortedPlugins; 1332} 1333 1334async function setPluginEnabled(plugin: Plugin, enabled?: boolean) { 1335 const target = plugins.find(it => it.hash === plugin.hash); 1336 if (target) { 1337 target.state = enabled ? 'enabled' : 'disabled'; 1338 plugins = [...plugins]; 1339 pluginStateMapper.notify(); 1340 PluginMeta.setPluginMetaProp(plugin, 'enabled', enabled); 1341 } 1342} 1343 1344const PluginManager = { 1345 setup, 1346 installPlugin, 1347 installPluginFromRawCode, 1348 installPluginFromUrl, 1349 updatePlugin, 1350 uninstallPlugin, 1351 getByMedia, 1352 getByHash, 1353 getByName, 1354 getValidPlugins, 1355 getSearchablePlugins, 1356 getSortedSearchablePlugins, 1357 getTopListsablePlugins, 1358 getSortedRecommendSheetablePlugins, 1359 getSortedTopListsablePlugins, 1360 usePlugins: pluginStateMapper.useMappedState, 1361 useSortedPlugins, 1362 uninstallAllPlugins, 1363 setPluginEnabled, 1364}; 1365 1366export default PluginManager; 1367