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