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