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