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