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