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