xref: /MusicFree/src/core/pluginManager.ts (revision 4394410db4efa6b5c0fcf928c2663d5934cef564)
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: any) {
432            trace('获取专辑信息失败', e?.message);
433            return {...albumItem, musicList: []};
434        }
435    }
436
437    /** 查询作者信息 */
438    async getArtistWorks<T extends IArtist.ArtistMediaType>(
439        artistItem: IArtist.IArtistItem,
440        page: number,
441        type: T,
442    ): Promise<IPlugin.ISearchResult<T>> {
443        if (!this.plugin.instance.getArtistWorks) {
444            return {
445                isEnd: true,
446                data: [],
447            };
448        }
449        try {
450            const result = await this.plugin.instance.getArtistWorks(
451                artistItem,
452                page,
453                type,
454            );
455            if (!result.data) {
456                return {
457                    isEnd: true,
458                    data: [],
459                };
460            }
461            result.data?.forEach(_ => resetMediaItem(_, this.plugin.name));
462            return {
463                isEnd: result.isEnd ?? true,
464                data: result.data,
465            };
466        } catch (e: any) {
467            trace('查询作者信息失败', e?.message);
468            throw e;
469        }
470    }
471
472    /** 导入歌单 */
473    async importMusicSheet(urlLike: string): Promise<IMusic.IMusicItem[]> {
474        try {
475            const result =
476                (await this.plugin.instance?.importMusicSheet?.(urlLike)) ?? [];
477            result.forEach(_ => resetMediaItem(_, this.plugin.name));
478            return result;
479        } catch (e) {
480            console.log(e);
481            return [];
482        }
483    }
484    /** 导入单曲 */
485    async importMusicItem(urlLike: string): Promise<IMusic.IMusicItem | null> {
486        try {
487            const result = await this.plugin.instance?.importMusicItem?.(
488                urlLike,
489            );
490            if (!result) {
491                throw new Error();
492            }
493            resetMediaItem(result, this.plugin.name);
494            return result;
495        } catch {
496            return null;
497        }
498    }
499}
500
501let plugins: Array<Plugin> = [];
502const pluginStateMapper = new StateMapper(() => plugins);
503
504/** 本地插件 */
505const localFilePlugin = new Plugin(function () {
506    return {
507        platform: '本地', //todo 改成常量
508        _path: '',
509        async getMusicInfo(musicBase) {
510            const localPath = getInternalData<string>(
511                musicBase,
512                InternalDataType.LOCALPATH,
513            );
514            if (localPath) {
515                const coverImg = await Mp3Util.getMediaCoverImg(localPath);
516                return {
517                    artwork: coverImg,
518                };
519            }
520            return null;
521        },
522    };
523}, '');
524
525async function setup() {
526    const _plugins: Array<Plugin> = [];
527    try {
528        // 加载插件
529        const pluginsPaths = await readDir(pathConst.pluginPath);
530        for (let i = 0; i < pluginsPaths.length; ++i) {
531            const _pluginUrl = pluginsPaths[i];
532            trace('初始化插件', _pluginUrl);
533            if (
534                _pluginUrl.isFile() &&
535                (_pluginUrl.name?.endsWith?.('.js') ||
536                    _pluginUrl.path?.endsWith?.('.js'))
537            ) {
538                const funcCode = await readFile(_pluginUrl.path, 'utf8');
539                const plugin = new Plugin(funcCode, _pluginUrl.path);
540                const _pluginIndex = _plugins.findIndex(
541                    p => p.hash === plugin.hash,
542                );
543                if (_pluginIndex !== -1) {
544                    // 重复插件,直接忽略
545                    return;
546                }
547                plugin.hash !== '' && _plugins.push(plugin);
548            }
549        }
550
551        plugins = _plugins;
552        pluginStateMapper.notify();
553    } catch (e: any) {
554        ToastAndroid.show(
555            `插件初始化失败:${e?.message ?? e}`,
556            ToastAndroid.LONG,
557        );
558        errorLog('插件初始化失败', e?.message);
559        throw e;
560    }
561}
562
563// 安装插件
564async function installPlugin(pluginPath: string) {
565    // if (pluginPath.endsWith('.js')) {
566    const funcCode = await readFile(pluginPath, 'utf8');
567    const plugin = new Plugin(funcCode, pluginPath);
568    const _pluginIndex = plugins.findIndex(p => p.hash === plugin.hash);
569    if (_pluginIndex !== -1) {
570        throw new Error('插件已安装');
571    }
572    if (plugin.hash !== '') {
573        const fn = nanoid();
574        const _pluginPath = `${pathConst.pluginPath}${fn}.js`;
575        await copyFile(pluginPath, _pluginPath);
576        plugin.path = _pluginPath;
577        plugins = plugins.concat(plugin);
578        pluginStateMapper.notify();
579        return;
580    }
581    throw new Error('插件无法解析');
582    // }
583    // throw new Error('插件不存在');
584}
585
586async function installPluginFromUrl(url: string) {
587    try {
588        const funcCode = (await axios.get(url)).data;
589        if (funcCode) {
590            const plugin = new Plugin(funcCode, '');
591            const _pluginIndex = plugins.findIndex(p => p.hash === plugin.hash);
592            if (_pluginIndex !== -1) {
593                // 静默忽略
594                return;
595            }
596            const oldVersionPlugin = plugins.find(p => p.name === plugin.name);
597            if (oldVersionPlugin) {
598                if (
599                    compare(
600                        oldVersionPlugin.instance.version ?? '',
601                        plugin.instance.version ?? '',
602                        '>',
603                    )
604                ) {
605                    throw new Error('已安装更新版本的插件');
606                }
607            }
608
609            if (plugin.hash !== '') {
610                const fn = nanoid();
611                const _pluginPath = `${pathConst.pluginPath}${fn}.js`;
612                await writeFile(_pluginPath, funcCode, 'utf8');
613                plugin.path = _pluginPath;
614                plugins = plugins.concat(plugin);
615                if (oldVersionPlugin) {
616                    plugins = plugins.filter(
617                        _ => _.hash !== oldVersionPlugin.hash,
618                    );
619                    try {
620                        await unlink(oldVersionPlugin.path);
621                    } catch {}
622                }
623                pluginStateMapper.notify();
624                return;
625            }
626            throw new Error('插件无法解析');
627        }
628    } catch (e: any) {
629        errorLog('URL安装插件失败', e);
630        throw new Error(e?.message ?? '');
631    }
632}
633
634/** 卸载插件 */
635async function uninstallPlugin(hash: string) {
636    const targetIndex = plugins.findIndex(_ => _.hash === hash);
637    if (targetIndex !== -1) {
638        try {
639            const pluginName = plugins[targetIndex].name;
640            await unlink(plugins[targetIndex].path);
641            plugins = plugins.filter(_ => _.hash !== hash);
642            pluginStateMapper.notify();
643            if (plugins.every(_ => _.name !== pluginName)) {
644                await MediaMeta.removePlugin(pluginName);
645            }
646        } catch {}
647    }
648}
649
650async function uninstallAllPlugins() {
651    await Promise.all(
652        plugins.map(async plugin => {
653            try {
654                const pluginName = plugin.name;
655                await unlink(plugin.path);
656                await MediaMeta.removePlugin(pluginName);
657            } catch (e) {}
658        }),
659    );
660    plugins = [];
661    pluginStateMapper.notify();
662}
663
664async function updatePlugin(plugin: Plugin) {
665    const updateUrl = plugin.instance.srcUrl;
666    if (!updateUrl) {
667        throw new Error('没有更新源');
668    }
669    try {
670        await installPluginFromUrl(updateUrl);
671    } catch (e: any) {
672        if (e.message === '插件已安装') {
673            throw new Error('当前已是最新版本');
674        } else {
675            throw e;
676        }
677    }
678}
679
680function getByMedia(mediaItem: ICommon.IMediaBase) {
681    return getByName(mediaItem.platform);
682}
683
684function getByHash(hash: string) {
685    return plugins.find(_ => _.hash === hash);
686}
687
688function getByName(name: string) {
689    return name === '本地'
690        ? localFilePlugin
691        : plugins.find(_ => _.name === name);
692}
693
694function getValidPlugins() {
695    return plugins.filter(_ => _.state === 'enabled');
696}
697
698function getSearchablePlugins() {
699    return plugins.filter(_ => _.state === 'enabled' && _.instance.search);
700}
701
702const PluginManager = {
703    setup,
704    installPlugin,
705    installPluginFromUrl,
706    updatePlugin,
707    uninstallPlugin,
708    getByMedia,
709    getByHash,
710    getByName,
711    getValidPlugins,
712    getSearchablePlugins,
713    usePlugins: pluginStateMapper.useMappedState,
714    uninstallAllPlugins,
715};
716
717export default PluginManager;
718