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