xref: /MusicFree/src/core/pluginManager.ts (revision 1a5528a05d031f93765f14da643092943758d6dc)
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            this.checkValid(_instance);
71        } catch (e: any) {
72            this.state = 'error';
73            this.stateCode = PluginStateCode.CannotParse;
74            if (e?.stateCode) {
75                this.stateCode = e.stateCode;
76            }
77            errorLog(`${pluginPath}插件无法解析 `, {
78                stateCode: this.stateCode,
79                message: e?.message,
80                stack: e?.stack,
81            });
82            _instance = e?.instance ?? {
83                _path: '',
84                platform: '',
85                appVersion: '',
86                async getMusicTrack() {
87                    return null;
88                },
89                async search() {
90                    return {};
91                },
92                async getAlbumInfo() {
93                    return null;
94                },
95            };
96        }
97        this.instance = _instance;
98        this.path = pluginPath;
99        this.name = _instance.platform;
100        if (this.instance.platform === '') {
101            this.hash = '';
102        } else {
103            this.hash = sha256(funcCode).toString();
104        }
105
106        // 放在最后
107        this.methods = new PluginMethods(this);
108    }
109
110    private checkValid(_instance: IPlugin.IPluginInstance) {
111        // 总不会一个都没有吧
112        const keys: Array<keyof IPlugin.IPluginInstance> = [
113            'getAlbumInfo',
114            'search',
115            'getMusicTrack',
116        ];
117        if (keys.every(k => !_instance[k])) {
118            throw {
119                instance: _instance,
120                stateCode: PluginStateCode.NotComplete,
121            };
122        }
123        /** 版本号校验 */
124        if (
125            _instance.appVersion &&
126            !satisfies(DeviceInfo.getVersion(), _instance.appVersion)
127        ) {
128            throw {
129                instance: _instance,
130                stateCode: PluginStateCode.VersionNotMatch,
131            };
132        }
133        return true;
134    }
135}
136
137/** 有缓存等信息 */
138class PluginMethods implements IPlugin.IPluginInstanceMethods {
139    private plugin;
140    constructor(plugin: Plugin) {
141        this.plugin = plugin;
142    }
143    /** 搜索 */
144    async search<T extends ICommon.SupportMediaType>(
145        query: string,
146        page: number,
147        type: T,
148    ): Promise<IPlugin.ISearchResult<T>> {
149        if (!this.plugin.instance.search) {
150            return {
151                isEnd: true,
152                data: [],
153            };
154        }
155
156        const result =
157            (await this.plugin.instance.search(query, page, type)) ?? {};
158        if (Array.isArray(result.data)) {
159            result.data.forEach(_ => {
160                resetMediaItem(_, this.plugin.name);
161            });
162            return {
163                isEnd: result.isEnd ?? true,
164                data: result.data,
165            };
166        }
167        return {
168            isEnd: true,
169            data: [],
170        };
171    }
172
173    /** 获取真实源 */
174    async getMusicTrack(
175        musicItem: IMusic.IMusicItemBase,
176        retryCount = 1,
177    ): Promise<IPlugin.IMusicTrackResult> {
178        // 1. 本地搜索 其实直接读mediameta就好了
179        const localPath =
180            musicItem?.[internalSymbolKey]?.localPath ??
181            Download.getDownloaded(musicItem)?.[internalSymbolKey]?.localPath;
182        if (localPath && (await exists(localPath))) {
183            trace('播放', '本地播放');
184            return {
185                url: localPath,
186            };
187        }
188        // 2. 缓存播放
189        const mediaCache = Cache.get(musicItem);
190        if (mediaCache && mediaCache?.url) {
191            trace('播放', '缓存播放');
192            return {
193                url: mediaCache.url,
194                headers: mediaCache.headers,
195                userAgent:
196                    mediaCache.userAgent ?? mediaCache.headers?.['user-agent'],
197            };
198        }
199        // 3. 插件解析
200        if (!this.plugin.instance.getMusicTrack) {
201            return {url: musicItem.url};
202        }
203        try {
204            const {url, headers} =
205                (await this.plugin.instance.getMusicTrack(musicItem)) ?? {};
206            if (!url) {
207                throw new Error();
208            }
209            trace('播放', '插件播放');
210            const result = {
211                url,
212                headers,
213                userAgent: headers?.['user-agent'],
214            };
215
216            Cache.update(musicItem, result);
217            return result;
218        } catch (e: any) {
219            if (retryCount > 0) {
220                await delay(150);
221                return this.getMusicTrack(musicItem, --retryCount);
222            }
223            errorLog('获取真实源失败', e?.message);
224            throw e;
225        }
226    }
227
228    /** 获取音乐详情 */
229    async getMusicInfo(
230        musicItem: ICommon.IMediaBase,
231    ): Promise<IMusic.IMusicItem | null> {
232        if (!this.plugin.instance.getMusicInfo) {
233            return musicItem as IMusic.IMusicItem;
234        }
235        return (
236            this.plugin.instance.getMusicInfo(
237                resetMediaItem(musicItem, undefined, true),
238            ) ?? musicItem
239        );
240    }
241
242    /** 获取歌词 */
243    async getLyric(
244        musicItem: IMusic.IMusicItemBase,
245        from?: IMusic.IMusicItemBase,
246    ): Promise<ILyric.ILyricSource | null> {
247        // 1.额外存储的meta信息
248        const meta = MediaMeta.get(musicItem);
249        if (meta && meta.associatedLrc) {
250            // 有关联歌词
251            if (
252                isSameMediaItem(musicItem, from) ||
253                isSameMediaItem(meta.associatedLrc, musicItem)
254            ) {
255                // 形成环路,断开当前的环
256                await MediaMeta.update(musicItem, {
257                    associatedLrc: undefined,
258                });
259                // 无歌词
260                return null;
261            }
262            // 获取关联歌词
263            const result = await this.getLyric(
264                meta.associatedLrc,
265                from ?? musicItem,
266            );
267            if (result) {
268                // 如果有关联歌词,就返回关联歌词,深度优先
269                return result;
270            }
271        }
272        const cache = Cache.get(musicItem);
273        let rawLrc = meta?.rawLrc || musicItem.rawLrc || cache?.rawLrc;
274        let lrcUrl = meta?.lrc || musicItem.lrc || cache?.lrc;
275        // 如果存在文本
276        if (rawLrc) {
277            return {
278                rawLrc,
279                lrc: lrcUrl,
280            };
281        }
282        // 2.本地缓存
283        const localLrc =
284            meta?.[internalSerialzeKey]?.local?.localLrc ||
285            cache?.[internalSerialzeKey]?.local?.localLrc;
286        if (localLrc && (await exists(localLrc))) {
287            rawLrc = await readFile(localLrc, 'utf8');
288            return {
289                rawLrc,
290                lrc: lrcUrl,
291            };
292        }
293        // 3.优先使用url
294        if (lrcUrl) {
295            try {
296                // 需要超时时间 axios timeout 但是没生效
297                rawLrc = (await axios.get(lrcUrl)).data;
298                return {
299                    rawLrc,
300                    lrc: lrcUrl,
301                };
302            } catch {
303                lrcUrl = undefined;
304            }
305        }
306        // 4. 如果地址失效
307        if (!lrcUrl) {
308            // 插件获得url
309            try {
310                const lrcSource = await this.plugin.instance?.getLyric?.(
311                    resetMediaItem(musicItem, undefined, true),
312                );
313                rawLrc = lrcSource?.rawLrc;
314                lrcUrl = lrcSource?.lrc;
315            } catch (e: any) {
316                trace('插件获取歌词失败', e?.message, 'error');
317            }
318        }
319        // 5. 最后一次请求
320        if (rawLrc || lrcUrl) {
321            const filename = `${pathConst.lrcCachePath}${nanoid()}.lrc`;
322            if (lrcUrl) {
323                try {
324                    rawLrc = (await axios.get(lrcUrl)).data;
325                } catch {}
326            }
327            if (rawLrc) {
328                await writeFile(filename, rawLrc, 'utf8');
329                // 写入缓存
330                Cache.update(musicItem, [
331                    [`${internalSerialzeKey}.local.localLrc`, filename],
332                ]);
333                // 如果有meta
334                if (meta) {
335                    MediaMeta.update(musicItem, [
336                        [`${internalSerialzeKey}.local.localLrc`, filename],
337                    ]);
338                }
339                return {
340                    rawLrc,
341                    lrc: lrcUrl,
342                };
343            }
344        }
345
346        return null;
347    }
348
349    /** 获取歌词文本 */
350    async getLyricText(
351        musicItem: IMusic.IMusicItem,
352    ): Promise<string | undefined> {
353        return (await this.getLyric(musicItem))?.rawLrc;
354    }
355
356    /** 获取专辑信息 */
357    async getAlbumInfo(
358        albumItem: IAlbum.IAlbumItemBase,
359    ): Promise<IAlbum.IAlbumItem | null> {
360        if (!this.plugin.instance.getAlbumInfo) {
361            return {...albumItem, musicList: []};
362        }
363        try {
364            const result = await this.plugin.instance.getAlbumInfo(
365                resetMediaItem(albumItem, undefined, true),
366            );
367            if (!result) {
368                throw new Error();
369            }
370            result?.musicList?.forEach(_ => {
371                resetMediaItem(_, this.plugin.name);
372            });
373
374            return {...albumItem, ...result};
375        } catch {
376            return {...albumItem, musicList: []};
377        }
378    }
379
380    /** 查询作者信息 */
381    async queryArtistWorks<T extends IArtist.ArtistMediaType>(
382        artistItem: IArtist.IArtistItem,
383        page: number,
384        type: T,
385    ): Promise<IPlugin.ISearchResult<T>> {
386        if (!this.plugin.instance.queryArtistWorks) {
387            return {
388                isEnd: true,
389                data: [],
390            };
391        }
392        try {
393            const result = await this.plugin.instance.queryArtistWorks(
394                artistItem,
395                page,
396                type,
397            );
398            if (!result.data) {
399                return {
400                    isEnd: true,
401                    data: [],
402                };
403            }
404            result.data?.forEach(_ => resetMediaItem(_, this.plugin.name));
405            return {
406                isEnd: result.isEnd ?? true,
407                data: result.data,
408            };
409        } catch (e) {
410            throw e;
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