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