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