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