xref: /MusicFree/src/core/pluginManager.ts (revision cd669353b6a483aad2a61863c7df332c267907c6)
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        if (musicItem.platform === '本地') {
202            throw new Error('本地音乐不存在');
203        }
204        // 2. 缓存播放
205        const mediaCache = Cache.get(musicItem);
206        const pluginCacheControl = this.plugin.instance.cacheControl;
207        if (
208            mediaCache &&
209            mediaCache?.url &&
210            (pluginCacheControl === CacheControl.Cache ||
211                (pluginCacheControl === CacheControl.NoCache &&
212                    Network.isOffline()))
213        ) {
214            trace('播放', '缓存播放');
215            return {
216                url: mediaCache.url,
217                headers: mediaCache.headers,
218                userAgent:
219                    mediaCache.userAgent ?? mediaCache.headers?.['user-agent'],
220            };
221        }
222        // 3. 插件解析
223        if (!this.plugin.instance.getMediaSource) {
224            return {url: musicItem.url};
225        }
226        try {
227            const {url, headers} =
228                (await this.plugin.instance.getMediaSource(musicItem)) ?? {};
229            if (!url) {
230                throw new Error();
231            }
232            trace('播放', '插件播放');
233            const result = {
234                url,
235                headers,
236                userAgent: headers?.['user-agent'],
237            } as IPlugin.IMediaSourceResult;
238
239            if (pluginCacheControl !== CacheControl.NoStore) {
240                Cache.update(musicItem, result);
241            }
242
243            return result;
244        } catch (e: any) {
245            console.log('eeee', e);
246            if (retryCount > 0) {
247                await delay(150);
248                return this.getMediaSource(musicItem, --retryCount);
249            }
250            errorLog('获取真实源失败', e?.message);
251            throw e;
252        }
253    }
254
255    /** 获取音乐详情 */
256    async getMusicInfo(
257        musicItem: ICommon.IMediaBase,
258    ): Promise<IMusic.IMusicItem | null> {
259        if (!this.plugin.instance.getMusicInfo) {
260            return musicItem as IMusic.IMusicItem;
261        }
262        return (
263            this.plugin.instance.getMusicInfo(
264                resetMediaItem(musicItem, undefined, true),
265            ) ?? musicItem
266        );
267    }
268
269    /** 获取歌词 */
270    async getLyric(
271        musicItem: IMusic.IMusicItemBase,
272        from?: IMusic.IMusicItemBase,
273    ): Promise<ILyric.ILyricSource | null> {
274        // 1.额外存储的meta信息
275        const meta = MediaMeta.get(musicItem);
276        if (meta && meta.associatedLrc) {
277            // 有关联歌词
278            if (
279                isSameMediaItem(musicItem, from) ||
280                isSameMediaItem(meta.associatedLrc, musicItem)
281            ) {
282                // 形成环路,断开当前的环
283                await MediaMeta.update(musicItem, {
284                    associatedLrc: undefined,
285                });
286                // 无歌词
287                return null;
288            }
289            // 获取关联歌词
290            const associatedMeta = MediaMeta.get(meta.associatedLrc) ?? {};
291            const result = await this.getLyric(
292                {...meta.associatedLrc, ...associatedMeta},
293                from ?? musicItem,
294            );
295            if (result) {
296                // 如果有关联歌词,就返回关联歌词,深度优先
297                return result;
298            }
299        }
300        const cache = Cache.get(musicItem);
301        let rawLrc = meta?.rawLrc || musicItem.rawLrc || cache?.rawLrc;
302        let lrcUrl = meta?.lrc || musicItem.lrc || cache?.lrc;
303        // 如果存在文本
304        if (rawLrc) {
305            return {
306                rawLrc,
307                lrc: lrcUrl,
308            };
309        }
310        // 2.本地缓存
311        const localLrc =
312            meta?.[internalSerializeKey]?.local?.localLrc ||
313            cache?.[internalSerializeKey]?.local?.localLrc;
314        if (localLrc && (await exists(localLrc))) {
315            rawLrc = await readFile(localLrc, 'utf8');
316            return {
317                rawLrc,
318                lrc: lrcUrl,
319            };
320        }
321        // 3.优先使用url
322        if (lrcUrl) {
323            try {
324                // 需要超时时间 axios timeout 但是没生效
325                rawLrc = (await axios.get(lrcUrl, {timeout: 1500})).data;
326                return {
327                    rawLrc,
328                    lrc: lrcUrl,
329                };
330            } catch {
331                lrcUrl = undefined;
332            }
333        }
334        // 4. 如果地址失效
335        if (!lrcUrl) {
336            // 插件获得url
337            try {
338                let lrcSource;
339                if (from) {
340                    lrcSource = await PluginManager.getByMedia(
341                        musicItem,
342                    )?.instance?.getLyric?.(
343                        resetMediaItem(musicItem, undefined, true),
344                    );
345                } else {
346                    lrcSource = await this.plugin.instance?.getLyric?.(
347                        resetMediaItem(musicItem, undefined, true),
348                    );
349                }
350
351                rawLrc = lrcSource?.rawLrc;
352                lrcUrl = lrcSource?.lrc;
353            } catch (e: any) {
354                trace('插件获取歌词失败', e?.message, 'error');
355            }
356        }
357        // 5. 最后一次请求
358        if (rawLrc || lrcUrl) {
359            const filename = `${pathConst.lrcCachePath}${nanoid()}.lrc`;
360            if (lrcUrl) {
361                try {
362                    rawLrc = (await axios.get(lrcUrl, {timeout: 1500})).data;
363                } catch {}
364            }
365            if (rawLrc) {
366                await writeFile(filename, rawLrc, 'utf8');
367                // 写入缓存
368                Cache.update(musicItem, [
369                    [`${internalSerializeKey}.local.localLrc`, filename],
370                ]);
371                // 如果有meta
372                if (meta) {
373                    MediaMeta.update(musicItem, [
374                        [`${internalSerializeKey}.local.localLrc`, filename],
375                    ]);
376                }
377                return {
378                    rawLrc,
379                    lrc: lrcUrl,
380                };
381            }
382        }
383
384        return null;
385    }
386
387    /** 获取歌词文本 */
388    async getLyricText(
389        musicItem: IMusic.IMusicItem,
390    ): Promise<string | undefined> {
391        return (await this.getLyric(musicItem))?.rawLrc;
392    }
393
394    /** 获取专辑信息 */
395    async getAlbumInfo(
396        albumItem: IAlbum.IAlbumItemBase,
397    ): Promise<IAlbum.IAlbumItem | null> {
398        if (!this.plugin.instance.getAlbumInfo) {
399            return {...albumItem, musicList: []};
400        }
401        try {
402            const result = await this.plugin.instance.getAlbumInfo(
403                resetMediaItem(albumItem, undefined, true),
404            );
405            if (!result) {
406                throw new Error();
407            }
408            result?.musicList?.forEach(_ => {
409                resetMediaItem(_, this.plugin.name);
410            });
411
412            return {...albumItem, ...result};
413        } catch (e) {
414            return {...albumItem, musicList: []};
415        }
416    }
417
418    /** 查询作者信息 */
419    async getArtistWorks<T extends IArtist.ArtistMediaType>(
420        artistItem: IArtist.IArtistItem,
421        page: number,
422        type: T,
423    ): Promise<IPlugin.ISearchResult<T>> {
424        if (!this.plugin.instance.getArtistWorks) {
425            return {
426                isEnd: true,
427                data: [],
428            };
429        }
430        try {
431            const result = await this.plugin.instance.getArtistWorks(
432                artistItem,
433                page,
434                type,
435            );
436            if (!result.data) {
437                return {
438                    isEnd: true,
439                    data: [],
440                };
441            }
442            result.data?.forEach(_ => resetMediaItem(_, this.plugin.name));
443            return {
444                isEnd: result.isEnd ?? true,
445                data: result.data,
446            };
447        } catch (e) {
448            throw e;
449        }
450    }
451
452    /** 导入歌单 */
453    async importMusicSheet(urlLike: string): Promise<IMusic.IMusicItem[]> {
454        try {
455            const result =
456                (await this.plugin.instance?.importMusicSheet?.(urlLike)) ?? [];
457            result.forEach(_ => resetMediaItem(_, this.plugin.name));
458            return result;
459        } catch (e) {
460            console.log(e);
461            return [];
462        }
463    }
464    /** 导入单曲 */
465    async importMusicItem(urlLike: string): Promise<IMusic.IMusicItem | null> {
466        try {
467            const result = await this.plugin.instance?.importMusicItem?.(
468                urlLike,
469            );
470            if (!result) {
471                throw new Error();
472            }
473            resetMediaItem(result, this.plugin.name);
474            return result;
475        } catch {
476            return null;
477        }
478    }
479}
480
481let plugins: Array<Plugin> = [];
482const pluginStateMapper = new StateMapper(() => plugins);
483const localFilePlugin = new Plugin(
484    `function (){
485    return {
486        platform: '本地',
487    }
488}`,
489    '',
490);
491
492async function setup() {
493    const _plugins: Array<Plugin> = [];
494    try {
495        // 加载插件
496        const pluginsPaths = await readDir(pathConst.pluginPath);
497        for (let i = 0; i < pluginsPaths.length; ++i) {
498            const _pluginUrl = pluginsPaths[i];
499            trace('初始化插件', _pluginUrl);
500            if (
501                _pluginUrl.isFile() &&
502                (_pluginUrl.name?.endsWith?.('.js') ||
503                    _pluginUrl.path?.endsWith?.('.js'))
504            ) {
505                const funcCode = await readFile(_pluginUrl.path, 'utf8');
506                const plugin = new Plugin(funcCode, _pluginUrl.path);
507                const _pluginIndex = _plugins.findIndex(
508                    p => p.hash === plugin.hash,
509                );
510                if (_pluginIndex !== -1) {
511                    // 重复插件,直接忽略
512                    return;
513                }
514                plugin.hash !== '' && _plugins.push(plugin);
515            }
516        }
517
518        plugins = _plugins;
519        pluginStateMapper.notify();
520    } catch (e: any) {
521        ToastAndroid.show(
522            `插件初始化失败:${e?.message ?? e}`,
523            ToastAndroid.LONG,
524        );
525        errorLog('插件初始化失败', e?.message);
526        throw e;
527    }
528}
529
530// 安装插件
531async function installPlugin(pluginPath: string) {
532    if (pluginPath.endsWith('.js')) {
533        const funcCode = await readFile(pluginPath, 'utf8');
534        const plugin = new Plugin(funcCode, pluginPath);
535        const _pluginIndex = plugins.findIndex(p => p.hash === plugin.hash);
536        if (_pluginIndex !== -1) {
537            throw new Error('插件已安装');
538        }
539        if (plugin.hash !== '') {
540            const fn = nanoid();
541            const _pluginPath = `${pathConst.pluginPath}${fn}.js`;
542            await copyFile(pluginPath, _pluginPath);
543            plugin.path = _pluginPath;
544            plugins = plugins.concat(plugin);
545            pluginStateMapper.notify();
546            return;
547        }
548        throw new Error('插件无法解析');
549    }
550    throw new Error('插件不存在');
551}
552
553async function installPluginFromUrl(url: string) {
554    try {
555        const funcCode = (await axios.get(url)).data;
556        if (funcCode) {
557            const plugin = new Plugin(funcCode, '');
558            const _pluginIndex = plugins.findIndex(p => p.hash === plugin.hash);
559            if (_pluginIndex !== -1) {
560                // 静默忽略
561                return;
562            }
563            const oldVersionPlugin = plugins.find(p => p.name === plugin.name);
564            if (oldVersionPlugin) {
565                if (
566                    compare(
567                        oldVersionPlugin.instance.version ?? '',
568                        plugin.instance.version ?? '',
569                        '>',
570                    )
571                ) {
572                    throw new Error('已安装更新版本的插件');
573                }
574            }
575
576            if (plugin.hash !== '') {
577                const fn = nanoid();
578                const _pluginPath = `${pathConst.pluginPath}${fn}.js`;
579                await writeFile(_pluginPath, funcCode, 'utf8');
580                plugin.path = _pluginPath;
581                plugins = plugins.concat(plugin);
582                if (oldVersionPlugin) {
583                    plugins = plugins.filter(
584                        _ => _.hash !== oldVersionPlugin.hash,
585                    );
586                    try {
587                        await unlink(oldVersionPlugin.path);
588                    } catch {}
589                }
590                pluginStateMapper.notify();
591                return;
592            }
593            throw new Error('插件无法解析');
594        }
595    } catch (e: any) {
596        errorLog('URL安装插件失败', e);
597        throw new Error(e?.message ?? '');
598    }
599}
600
601/** 卸载插件 */
602async function uninstallPlugin(hash: string) {
603    const targetIndex = plugins.findIndex(_ => _.hash === hash);
604    if (targetIndex !== -1) {
605        try {
606            const pluginName = plugins[targetIndex].name;
607            await unlink(plugins[targetIndex].path);
608            plugins = plugins.filter(_ => _.hash !== hash);
609            pluginStateMapper.notify();
610            if (plugins.every(_ => _.name !== pluginName)) {
611                await MediaMeta.removePlugin(pluginName);
612            }
613        } catch {}
614    }
615}
616
617async function uninstallAllPlugins() {
618    await Promise.all(
619        plugins.map(async plugin => {
620            try {
621                const pluginName = plugin.name;
622                await unlink(plugin.path);
623                await MediaMeta.removePlugin(pluginName);
624            } catch (e) {}
625        }),
626    );
627    plugins = [];
628    pluginStateMapper.notify();
629}
630
631async function updatePlugin(plugin: Plugin) {
632    const updateUrl = plugin.instance.srcUrl;
633    if (!updateUrl) {
634        throw new Error('没有更新源');
635    }
636    try {
637        await installPluginFromUrl(updateUrl);
638    } catch (e: any) {
639        if (e.message === '插件已安装') {
640            throw new Error('当前已是最新版本');
641        } else {
642            throw e;
643        }
644    }
645}
646
647function getByMedia(mediaItem: ICommon.IMediaBase) {
648    return getByName(mediaItem.platform);
649}
650
651function getByHash(hash: string) {
652    return plugins.find(_ => _.hash === hash);
653}
654
655function getByName(name: string) {
656    return name === '本地'
657        ? localFilePlugin
658        : plugins.find(_ => _.name === name);
659}
660
661function getValidPlugins() {
662    return plugins.filter(_ => _.state === 'enabled');
663}
664
665function getSearchablePlugins() {
666    return plugins.filter(_ => _.state === 'enabled' && _.instance.search);
667}
668
669const PluginManager = {
670    setup,
671    installPlugin,
672    installPluginFromUrl,
673    updatePlugin,
674    uninstallPlugin,
675    getByMedia,
676    getByHash,
677    getByName,
678    getValidPlugins,
679    getSearchablePlugins,
680    usePlugins: pluginStateMapper.useMappedState,
681    uninstallAllPlugins,
682};
683
684export default PluginManager;
685