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