xref: /MusicFree/src/core/pluginManager.ts (revision e08d37a33b6e5f38f39dcb49b2eb1095f0d66a1c)
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 {ToastAndroid} from 'react-native';
15import pathConst from '@/constants/pathConst';
16import {compare, satisfies} from 'compare-versions';
17import DeviceInfo from 'react-native-device-info';
18import StateMapper from '@/utils/stateMapper';
19import MediaMeta from './mediaMeta';
20import {nanoid} from 'nanoid';
21import {errorLog, trace} from '../utils/log';
22import Cache from './cache';
23import {
24    getInternalData,
25    InternalDataType,
26    isSameMediaItem,
27    resetMediaItem,
28} from '@/utils/mediaItem';
29import {
30    CacheControl,
31    emptyFunction,
32    internalSerializeKey,
33    localPluginHash,
34    localPluginPlatform,
35} from '@/constants/commonConst';
36import delay from '@/utils/delay';
37import * as cheerio from 'cheerio';
38import CookieManager from '@react-native-cookies/cookies';
39import he from 'he';
40import Network from './network';
41import LocalMusicSheet from './localMusicSheet';
42import {FileSystem} from 'react-native-file-access';
43import Mp3Util from '@/native/mp3Util';
44import {PluginMeta} from './pluginMeta';
45
46axios.defaults.timeout = 1500;
47
48const sha256 = CryptoJs.SHA256;
49
50export enum PluginStateCode {
51    /** 版本不匹配 */
52    VersionNotMatch = 'VERSION NOT MATCH',
53    /** 无法解析 */
54    CannotParse = 'CANNOT PARSE',
55}
56
57//#region 插件类
58export class Plugin {
59    /** 插件名 */
60    public name: string;
61    /** 插件的hash,作为唯一id */
62    public hash: string;
63    /** 插件状态:激活、关闭、错误 */
64    public state: 'enabled' | 'disabled' | 'error';
65    /** 插件支持的搜索类型 */
66    public supportedSearchType?: string;
67    /** 插件状态信息 */
68    public stateCode?: PluginStateCode;
69    /** 插件的实例 */
70    public instance: IPlugin.IPluginInstance;
71    /** 插件路径 */
72    public path: string;
73    /** 插件方法 */
74    public methods: PluginMethods;
75    /** 用户输入 */
76    public userEnv?: Record<string, string>;
77
78    constructor(
79        funcCode: string | (() => IPlugin.IPluginInstance),
80        pluginPath: string,
81    ) {
82        this.state = 'enabled';
83        let _instance: IPlugin.IPluginInstance;
84        try {
85            if (typeof funcCode === 'string') {
86                // eslint-disable-next-line no-new-func
87                _instance = Function(`
88            'use strict';
89            try {
90              return ${funcCode};
91            } catch(e) {
92              return null;
93            }
94          `)()({
95                    CryptoJs,
96                    axios,
97                    dayjs,
98                    cheerio,
99                    bigInt,
100                    qs,
101                    he,
102                    CookieManager: {
103                        flush: CookieManager.flush,
104                        get: CookieManager.get,
105                    },
106                });
107            } else {
108                _instance = funcCode();
109            }
110            this.checkValid(_instance);
111        } catch (e: any) {
112            this.state = 'error';
113            this.stateCode = PluginStateCode.CannotParse;
114            if (e?.stateCode) {
115                this.stateCode = e.stateCode;
116            }
117            errorLog(`${pluginPath}插件无法解析 `, {
118                stateCode: this.stateCode,
119                message: e?.message,
120                stack: e?.stack,
121            });
122            _instance = e?.instance ?? {
123                _path: '',
124                platform: '',
125                appVersion: '',
126                async getMediaSource() {
127                    return null;
128                },
129                async search() {
130                    return {};
131                },
132                async getAlbumInfo() {
133                    return null;
134                },
135            };
136        }
137        this.instance = _instance;
138        this.path = pluginPath;
139        this.name = _instance.platform;
140        if (this.instance.platform === '') {
141            this.hash = '';
142        } else {
143            if (typeof funcCode === 'string') {
144                this.hash = sha256(funcCode).toString();
145            } else {
146                this.hash = sha256(funcCode.toString()).toString();
147            }
148        }
149
150        // 放在最后
151        this.methods = new PluginMethods(this);
152    }
153
154    private checkValid(_instance: IPlugin.IPluginInstance) {
155        /** 版本号校验 */
156        if (
157            _instance.appVersion &&
158            !satisfies(DeviceInfo.getVersion(), _instance.appVersion)
159        ) {
160            throw {
161                instance: _instance,
162                stateCode: PluginStateCode.VersionNotMatch,
163            };
164        }
165        return true;
166    }
167}
168//#endregion
169
170//#region 基于插件类封装的方法,供给APP侧直接调用
171/** 有缓存等信息 */
172class PluginMethods implements IPlugin.IPluginInstanceMethods {
173    private plugin;
174    constructor(plugin: Plugin) {
175        this.plugin = plugin;
176    }
177    /** 搜索 */
178    async search<T extends ICommon.SupportMediaType>(
179        query: string,
180        page: number,
181        type: T,
182    ): Promise<IPlugin.ISearchResult<T>> {
183        if (!this.plugin.instance.search) {
184            return {
185                isEnd: true,
186                data: [],
187            };
188        }
189
190        const result =
191            (await this.plugin.instance.search(query, page, type)) ?? {};
192        if (Array.isArray(result.data)) {
193            result.data.forEach(_ => {
194                resetMediaItem(_, this.plugin.name);
195            });
196            return {
197                isEnd: result.isEnd ?? true,
198                data: result.data,
199            };
200        }
201        return {
202            isEnd: true,
203            data: [],
204        };
205    }
206
207    /** 获取真实源 */
208    async getMediaSource(
209        musicItem: IMusic.IMusicItemBase,
210        retryCount = 1,
211    ): Promise<IPlugin.IMediaSourceResult> {
212        // 1. 本地搜索 其实直接读mediameta就好了
213        const localPath =
214            getInternalData<string>(musicItem, InternalDataType.LOCALPATH) ??
215            getInternalData<string>(
216                LocalMusicSheet.isLocalMusic(musicItem),
217                InternalDataType.LOCALPATH,
218            );
219        if (localPath && (await FileSystem.exists(localPath))) {
220            trace('本地播放', localPath);
221            return {
222                url: localPath,
223            };
224        }
225        if (musicItem.platform === localPluginPlatform) {
226            throw new Error('本地音乐不存在');
227        }
228        // 2. 缓存播放
229        const mediaCache = Cache.get(musicItem);
230        const pluginCacheControl =
231            this.plugin.instance.cacheControl ?? 'no-cache';
232        if (
233            mediaCache &&
234            mediaCache?.url &&
235            (pluginCacheControl === CacheControl.Cache ||
236                (pluginCacheControl === CacheControl.NoCache &&
237                    Network.isOffline()))
238        ) {
239            trace('播放', '缓存播放');
240            return {
241                url: mediaCache.url,
242                headers: mediaCache.headers,
243                userAgent:
244                    mediaCache.userAgent ?? mediaCache.headers?.['user-agent'],
245            };
246        }
247        // 3. 插件解析
248        if (!this.plugin.instance.getMediaSource) {
249            return {url: musicItem.url};
250        }
251        try {
252            const {url, headers} =
253                (await this.plugin.instance.getMediaSource(musicItem)) ?? {};
254            if (!url) {
255                throw new Error();
256            }
257            trace('播放', '插件播放');
258            const result = {
259                url,
260                headers,
261                userAgent: headers?.['user-agent'],
262            } as IPlugin.IMediaSourceResult;
263
264            if (pluginCacheControl !== CacheControl.NoStore) {
265                Cache.update(musicItem, result);
266            }
267
268            return result;
269        } catch (e: any) {
270            if (retryCount > 0) {
271                await delay(150);
272                return this.getMediaSource(musicItem, --retryCount);
273            }
274            errorLog('获取真实源失败', e?.message);
275            throw e;
276        }
277    }
278
279    /** 获取音乐详情 */
280    async getMusicInfo(
281        musicItem: ICommon.IMediaBase,
282    ): Promise<Partial<IMusic.IMusicItem> | null> {
283        if (!this.plugin.instance.getMusicInfo) {
284            return {};
285        }
286        try {
287            return (
288                this.plugin.instance.getMusicInfo(
289                    resetMediaItem(musicItem, undefined, true),
290                ) ?? {}
291            );
292        } catch (e) {
293            return {};
294        }
295    }
296
297    /** 获取歌词 */
298    async getLyric(
299        musicItem: IMusic.IMusicItemBase,
300        from?: IMusic.IMusicItemBase,
301    ): Promise<ILyric.ILyricSource | null> {
302        // 1.额外存储的meta信息
303        const meta = MediaMeta.get(musicItem);
304        if (meta && meta.associatedLrc) {
305            // 有关联歌词
306            if (
307                isSameMediaItem(musicItem, from) ||
308                isSameMediaItem(meta.associatedLrc, musicItem)
309            ) {
310                // 形成环路,断开当前的环
311                await MediaMeta.update(musicItem, {
312                    associatedLrc: undefined,
313                });
314                // 无歌词
315                return null;
316            }
317            // 获取关联歌词
318            const associatedMeta = MediaMeta.get(meta.associatedLrc) ?? {};
319            const result = await this.getLyric(
320                {...meta.associatedLrc, ...associatedMeta},
321                from ?? musicItem,
322            );
323            if (result) {
324                // 如果有关联歌词,就返回关联歌词,深度优先
325                return result;
326            }
327        }
328        const cache = Cache.get(musicItem);
329        let rawLrc = meta?.rawLrc || musicItem.rawLrc || cache?.rawLrc;
330        let lrcUrl = meta?.lrc || musicItem.lrc || cache?.lrc;
331        // 如果存在文本
332        if (rawLrc) {
333            return {
334                rawLrc,
335                lrc: lrcUrl,
336            };
337        }
338        // 2.本地缓存
339        const localLrc =
340            meta?.[internalSerializeKey]?.local?.localLrc ||
341            cache?.[internalSerializeKey]?.local?.localLrc;
342        if (localLrc && (await exists(localLrc))) {
343            rawLrc = await readFile(localLrc, 'utf8');
344            return {
345                rawLrc,
346                lrc: lrcUrl,
347            };
348        }
349        // 3.优先使用url
350        if (lrcUrl) {
351            try {
352                // 需要超时时间 axios timeout 但是没生效
353                rawLrc = (await axios.get(lrcUrl, {timeout: 1500})).data;
354                return {
355                    rawLrc,
356                    lrc: lrcUrl,
357                };
358            } catch {
359                lrcUrl = undefined;
360            }
361        }
362        // 4. 如果地址失效
363        if (!lrcUrl) {
364            // 插件获得url
365            try {
366                let lrcSource;
367                if (from) {
368                    lrcSource = await PluginManager.getByMedia(
369                        musicItem,
370                    )?.instance?.getLyric?.(
371                        resetMediaItem(musicItem, undefined, true),
372                    );
373                } else {
374                    lrcSource = await this.plugin.instance?.getLyric?.(
375                        resetMediaItem(musicItem, undefined, true),
376                    );
377                }
378
379                rawLrc = lrcSource?.rawLrc;
380                lrcUrl = lrcSource?.lrc;
381            } catch (e: any) {
382                trace('插件获取歌词失败', e?.message, 'error');
383            }
384        }
385        // 5. 最后一次请求
386        if (rawLrc || lrcUrl) {
387            const filename = `${pathConst.lrcCachePath}${nanoid()}.lrc`;
388            if (lrcUrl) {
389                try {
390                    rawLrc = (await axios.get(lrcUrl, {timeout: 1500})).data;
391                } catch {}
392            }
393            if (rawLrc) {
394                await writeFile(filename, rawLrc, 'utf8');
395                // 写入缓存
396                Cache.update(musicItem, [
397                    [`${internalSerializeKey}.local.localLrc`, filename],
398                ]);
399                // 如果有meta
400                if (meta) {
401                    MediaMeta.update(musicItem, [
402                        [`${internalSerializeKey}.local.localLrc`, filename],
403                    ]);
404                }
405                return {
406                    rawLrc,
407                    lrc: lrcUrl,
408                };
409            }
410        }
411
412        return null;
413    }
414
415    /** 获取歌词文本 */
416    async getLyricText(
417        musicItem: IMusic.IMusicItem,
418    ): Promise<string | undefined> {
419        return (await this.getLyric(musicItem))?.rawLrc;
420    }
421
422    /** 获取专辑信息 */
423    async getAlbumInfo(
424        albumItem: IAlbum.IAlbumItemBase,
425    ): Promise<IAlbum.IAlbumItem | null> {
426        if (!this.plugin.instance.getAlbumInfo) {
427            return {...albumItem, musicList: []};
428        }
429        try {
430            const result = await this.plugin.instance.getAlbumInfo(
431                resetMediaItem(albumItem, undefined, true),
432            );
433            if (!result) {
434                throw new Error();
435            }
436            result?.musicList?.forEach(_ => {
437                resetMediaItem(_, this.plugin.name);
438            });
439
440            return {...albumItem, ...result};
441        } catch (e: any) {
442            trace('获取专辑信息失败', e?.message);
443            return {...albumItem, musicList: []};
444        }
445    }
446
447    /** 查询作者信息 */
448    async getArtistWorks<T extends IArtist.ArtistMediaType>(
449        artistItem: IArtist.IArtistItem,
450        page: number,
451        type: T,
452    ): Promise<IPlugin.ISearchResult<T>> {
453        if (!this.plugin.instance.getArtistWorks) {
454            return {
455                isEnd: true,
456                data: [],
457            };
458        }
459        try {
460            const result = await this.plugin.instance.getArtistWorks(
461                artistItem,
462                page,
463                type,
464            );
465            if (!result.data) {
466                return {
467                    isEnd: true,
468                    data: [],
469                };
470            }
471            result.data?.forEach(_ => resetMediaItem(_, this.plugin.name));
472            return {
473                isEnd: result.isEnd ?? true,
474                data: result.data,
475            };
476        } catch (e: any) {
477            trace('查询作者信息失败', e?.message);
478            throw e;
479        }
480    }
481
482    /** 导入歌单 */
483    async importMusicSheet(urlLike: string): Promise<IMusic.IMusicItem[]> {
484        try {
485            const result =
486                (await this.plugin.instance?.importMusicSheet?.(urlLike)) ?? [];
487            result.forEach(_ => resetMediaItem(_, this.plugin.name));
488            return result;
489        } catch (e) {
490            console.log(e);
491            return [];
492        }
493    }
494    /** 导入单曲 */
495    async importMusicItem(urlLike: string): Promise<IMusic.IMusicItem | null> {
496        try {
497            const result = await this.plugin.instance?.importMusicItem?.(
498                urlLike,
499            );
500            if (!result) {
501                throw new Error();
502            }
503            resetMediaItem(result, this.plugin.name);
504            return result;
505        } catch {
506            return null;
507        }
508    }
509}
510//#endregion
511
512let plugins: Array<Plugin> = [];
513const pluginStateMapper = new StateMapper(() => plugins);
514
515//#region 本地音乐插件
516/** 本地插件 */
517const localFilePlugin = new Plugin(function () {
518    return {
519        platform: localPluginPlatform,
520        _path: '',
521        async getMusicInfo(musicBase) {
522            const localPath = getInternalData<string>(
523                musicBase,
524                InternalDataType.LOCALPATH,
525            );
526            if (localPath) {
527                const coverImg = await Mp3Util.getMediaCoverImg(localPath);
528                return {
529                    artwork: coverImg,
530                };
531            }
532            return null;
533        },
534        async getLyric(musicBase) {
535            const localPath = getInternalData<string>(
536                musicBase,
537                InternalDataType.LOCALPATH,
538            );
539            if (localPath) {
540                const rawLrc = await Mp3Util.getLyric(localPath);
541                return {
542                    rawLrc,
543                };
544            }
545            return null;
546        },
547    };
548}, '');
549localFilePlugin.hash = localPluginHash;
550
551//#endregion
552
553async function setup() {
554    const _plugins: Array<Plugin> = [];
555    try {
556        // 加载插件
557        const pluginsPaths = await readDir(pathConst.pluginPath);
558        for (let i = 0; i < pluginsPaths.length; ++i) {
559            const _pluginUrl = pluginsPaths[i];
560            trace('初始化插件', _pluginUrl);
561            if (
562                _pluginUrl.isFile() &&
563                (_pluginUrl.name?.endsWith?.('.js') ||
564                    _pluginUrl.path?.endsWith?.('.js'))
565            ) {
566                const funcCode = await readFile(_pluginUrl.path, 'utf8');
567                const plugin = new Plugin(funcCode, _pluginUrl.path);
568                const _pluginIndex = _plugins.findIndex(
569                    p => p.hash === plugin.hash,
570                );
571                if (_pluginIndex !== -1) {
572                    // 重复插件,直接忽略
573                    return;
574                }
575                plugin.hash !== '' && _plugins.push(plugin);
576            }
577        }
578
579        plugins = _plugins;
580        pluginStateMapper.notify();
581        /** 初始化meta信息 */
582        PluginMeta.setupMeta(plugins.map(_ => _.name));
583    } catch (e: any) {
584        ToastAndroid.show(
585            `插件初始化失败:${e?.message ?? e}`,
586            ToastAndroid.LONG,
587        );
588        errorLog('插件初始化失败', e?.message);
589        throw e;
590    }
591}
592
593// 安装插件
594async function installPlugin(pluginPath: string) {
595    // if (pluginPath.endsWith('.js')) {
596    const funcCode = await readFile(pluginPath, 'utf8');
597    const plugin = new Plugin(funcCode, pluginPath);
598    const _pluginIndex = plugins.findIndex(p => p.hash === plugin.hash);
599    if (_pluginIndex !== -1) {
600        throw new Error('插件已安装');
601    }
602    if (plugin.hash !== '') {
603        const fn = nanoid();
604        const _pluginPath = `${pathConst.pluginPath}${fn}.js`;
605        await copyFile(pluginPath, _pluginPath);
606        plugin.path = _pluginPath;
607        plugins = plugins.concat(plugin);
608        pluginStateMapper.notify();
609        return;
610    }
611    throw new Error('插件无法解析');
612    // }
613    // throw new Error('插件不存在');
614}
615
616async function installPluginFromUrl(url: string) {
617    try {
618        const funcCode = (await axios.get(url)).data;
619        if (funcCode) {
620            const plugin = new Plugin(funcCode, '');
621            const _pluginIndex = plugins.findIndex(p => p.hash === plugin.hash);
622            if (_pluginIndex !== -1) {
623                // 静默忽略
624                return;
625            }
626            const oldVersionPlugin = plugins.find(p => p.name === plugin.name);
627            if (oldVersionPlugin) {
628                if (
629                    compare(
630                        oldVersionPlugin.instance.version ?? '',
631                        plugin.instance.version ?? '',
632                        '>',
633                    )
634                ) {
635                    throw new Error('已安装更新版本的插件');
636                }
637            }
638
639            if (plugin.hash !== '') {
640                const fn = nanoid();
641                const _pluginPath = `${pathConst.pluginPath}${fn}.js`;
642                await writeFile(_pluginPath, funcCode, 'utf8');
643                plugin.path = _pluginPath;
644                plugins = plugins.concat(plugin);
645                if (oldVersionPlugin) {
646                    plugins = plugins.filter(
647                        _ => _.hash !== oldVersionPlugin.hash,
648                    );
649                    try {
650                        await unlink(oldVersionPlugin.path);
651                    } catch {}
652                }
653                pluginStateMapper.notify();
654                return;
655            }
656            throw new Error('插件无法解析!');
657        }
658    } catch (e: any) {
659        errorLog('URL安装插件失败', e);
660        throw new Error(e?.message ?? '');
661    }
662}
663
664/** 卸载插件 */
665async function uninstallPlugin(hash: string) {
666    const targetIndex = plugins.findIndex(_ => _.hash === hash);
667    if (targetIndex !== -1) {
668        try {
669            const pluginName = plugins[targetIndex].name;
670            await unlink(plugins[targetIndex].path);
671            plugins = plugins.filter(_ => _.hash !== hash);
672            pluginStateMapper.notify();
673            if (plugins.every(_ => _.name !== pluginName)) {
674                await MediaMeta.removePlugin(pluginName);
675            }
676        } catch {}
677    }
678}
679
680async function uninstallAllPlugins() {
681    await Promise.all(
682        plugins.map(async plugin => {
683            try {
684                const pluginName = plugin.name;
685                await unlink(plugin.path);
686                await MediaMeta.removePlugin(pluginName);
687            } catch (e) {}
688        }),
689    );
690    plugins = [];
691    pluginStateMapper.notify();
692
693    /** 清除空余文件,异步做就可以了 */
694    readDir(pathConst.pluginPath)
695        .then(fns => {
696            fns.forEach(fn => {
697                unlink(fn.path).catch(emptyFunction);
698            });
699        })
700        .catch(emptyFunction);
701}
702
703async function updatePlugin(plugin: Plugin) {
704    const updateUrl = plugin.instance.srcUrl;
705    if (!updateUrl) {
706        throw new Error('没有更新源');
707    }
708    try {
709        await installPluginFromUrl(updateUrl);
710    } catch (e: any) {
711        if (e.message === '插件已安装') {
712            throw new Error('当前已是最新版本');
713        } else {
714            throw e;
715        }
716    }
717}
718
719function getByMedia(mediaItem: ICommon.IMediaBase) {
720    return getByName(mediaItem.platform);
721}
722
723function getByHash(hash: string) {
724    return hash === localPluginHash
725        ? localFilePlugin
726        : plugins.find(_ => _.hash === hash);
727}
728
729function getByName(name: string) {
730    return name === localPluginPlatform
731        ? localFilePlugin
732        : plugins.find(_ => _.name === name);
733}
734
735function getValidPlugins() {
736    return plugins.filter(_ => _.state === 'enabled');
737}
738
739function getSearchablePlugins() {
740    return plugins.filter(_ => _.state === 'enabled' && _.instance.search);
741}
742
743function getSortedSearchablePlugins() {
744    return getSearchablePlugins().sort((a, b) =>
745        (PluginMeta.getPluginMeta(a).order ?? Infinity) -
746            (PluginMeta.getPluginMeta(b).order ?? Infinity) <
747        0
748            ? -1
749            : 1,
750    );
751}
752
753function useSortedPlugins() {
754    const _plugins = pluginStateMapper.useMappedState();
755    const _pluginMetaAll = PluginMeta.usePluginMetaAll();
756
757    return [..._plugins].sort((a, b) =>
758        (_pluginMetaAll[a.name]?.order ?? Infinity) -
759            (_pluginMetaAll[b.name]?.order ?? Infinity) <
760        0
761            ? -1
762            : 1,
763    );
764}
765
766const PluginManager = {
767    setup,
768    installPlugin,
769    installPluginFromUrl,
770    updatePlugin,
771    uninstallPlugin,
772    getByMedia,
773    getByHash,
774    getByName,
775    getValidPlugins,
776    getSearchablePlugins,
777    getSortedSearchablePlugins,
778    usePlugins: pluginStateMapper.useMappedState,
779    useSortedPlugins,
780    uninstallAllPlugins,
781};
782
783export default PluginManager;
784