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