xref: /MusicFree/src/core/pluginManager.ts (revision 752ffc5abb93dbe20ee1b9fef4e24fadb07be115)
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, cache} =
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            if (cache !== false) {
217                Cache.update(musicItem, result);
218            }
219            return result;
220        } catch (e: any) {
221            if (retryCount > 0) {
222                await delay(150);
223                return this.getMusicTrack(musicItem, --retryCount);
224            }
225            errorLog('获取真实源失败', e?.message);
226            throw e;
227        }
228    }
229
230    /** 获取音乐详情 */
231    async getMusicInfo(
232        musicItem: ICommon.IMediaBase,
233    ): Promise<IMusic.IMusicItem | null> {
234        if (!this.plugin.instance.getMusicInfo) {
235            return musicItem as IMusic.IMusicItem;
236        }
237        return (
238            this.plugin.instance.getMusicInfo(
239                resetMediaItem(musicItem, undefined, true),
240            ) ?? musicItem
241        );
242    }
243
244    /** 获取歌词 */
245    async getLyric(
246        musicItem: IMusic.IMusicItemBase,
247        from?: IMusic.IMusicItemBase,
248    ): Promise<ILyric.ILyricSource | null> {
249        // 1.额外存储的meta信息
250        const meta = MediaMeta.get(musicItem);
251        if (meta && meta.associatedLrc) {
252            // 有关联歌词
253            if (
254                isSameMediaItem(musicItem, from) ||
255                isSameMediaItem(meta.associatedLrc, musicItem)
256            ) {
257                // 形成环路,断开当前的环
258                await MediaMeta.update(musicItem, {
259                    associatedLrc: undefined,
260                });
261                // 无歌词
262                return null;
263            }
264            // 获取关联歌词
265            const result = await this.getLyric(
266                meta.associatedLrc,
267                from ?? musicItem,
268            );
269            if (result) {
270                // 如果有关联歌词,就返回关联歌词,深度优先
271                return result;
272            }
273        }
274        const cache = Cache.get(musicItem);
275        let rawLrc = meta?.rawLrc || musicItem.rawLrc || cache?.rawLrc;
276        let lrcUrl = meta?.lrc || musicItem.lrc || cache?.lrc;
277        // 如果存在文本
278        if (rawLrc) {
279            return {
280                rawLrc,
281                lrc: lrcUrl,
282            };
283        }
284        // 2.本地缓存
285        const localLrc =
286            meta?.[internalSerialzeKey]?.local?.localLrc ||
287            cache?.[internalSerialzeKey]?.local?.localLrc;
288        if (localLrc && (await exists(localLrc))) {
289            rawLrc = await readFile(localLrc, 'utf8');
290            return {
291                rawLrc,
292                lrc: lrcUrl,
293            };
294        }
295        // 3.优先使用url
296        if (lrcUrl) {
297            try {
298                // 需要超时时间 axios timeout 但是没生效
299                rawLrc = (await axios.get(lrcUrl, {timeout: 1500})).data;
300                return {
301                    rawLrc,
302                    lrc: lrcUrl,
303                };
304            } catch {
305                lrcUrl = undefined;
306            }
307        }
308        // 4. 如果地址失效
309        if (!lrcUrl) {
310            // 插件获得url
311            try {
312                const lrcSource = await this.plugin.instance?.getLyric?.(
313                    resetMediaItem(musicItem, undefined, true),
314                );
315                rawLrc = lrcSource?.rawLrc;
316                lrcUrl = lrcSource?.lrc;
317            } catch (e: any) {
318                trace('插件获取歌词失败', e?.message, 'error');
319            }
320        }
321        // 5. 最后一次请求
322        if (rawLrc || lrcUrl) {
323            const filename = `${pathConst.lrcCachePath}${nanoid()}.lrc`;
324            if (lrcUrl) {
325                try {
326                    rawLrc = (await axios.get(lrcUrl, {timeout: 1500})).data;
327                } catch {}
328            }
329            if (rawLrc) {
330                await writeFile(filename, rawLrc, 'utf8');
331                // 写入缓存
332                Cache.update(musicItem, [
333                    [`${internalSerialzeKey}.local.localLrc`, filename],
334                ]);
335                // 如果有meta
336                if (meta) {
337                    MediaMeta.update(musicItem, [
338                        [`${internalSerialzeKey}.local.localLrc`, filename],
339                    ]);
340                }
341                return {
342                    rawLrc,
343                    lrc: lrcUrl,
344                };
345            }
346        }
347
348        return null;
349    }
350
351    /** 获取歌词文本 */
352    async getLyricText(
353        musicItem: IMusic.IMusicItem,
354    ): Promise<string | undefined> {
355        return (await this.getLyric(musicItem))?.rawLrc;
356    }
357
358    /** 获取专辑信息 */
359    async getAlbumInfo(
360        albumItem: IAlbum.IAlbumItemBase,
361    ): Promise<IAlbum.IAlbumItem | null> {
362        if (!this.plugin.instance.getAlbumInfo) {
363            return {...albumItem, musicList: []};
364        }
365        try {
366            const result = await this.plugin.instance.getAlbumInfo(
367                resetMediaItem(albumItem, undefined, true),
368            );
369            if (!result) {
370                throw new Error();
371            }
372            result?.musicList?.forEach(_ => {
373                resetMediaItem(_, this.plugin.name);
374            });
375
376            return {...albumItem, ...result};
377        } catch {
378            return {...albumItem, musicList: []};
379        }
380    }
381
382    /** 查询作者信息 */
383    async queryArtistWorks<T extends IArtist.ArtistMediaType>(
384        artistItem: IArtist.IArtistItem,
385        page: number,
386        type: T,
387    ): Promise<IPlugin.ISearchResult<T>> {
388        if (!this.plugin.instance.queryArtistWorks) {
389            return {
390                isEnd: true,
391                data: [],
392            };
393        }
394        try {
395            const result = await this.plugin.instance.queryArtistWorks(
396                artistItem,
397                page,
398                type,
399            );
400            if (!result.data) {
401                return {
402                    isEnd: true,
403                    data: [],
404                };
405            }
406            result.data?.forEach(_ => resetMediaItem(_, this.plugin.name));
407            return {
408                isEnd: result.isEnd ?? true,
409                data: result.data,
410            };
411        } catch (e) {
412            throw e;
413        }
414    }
415}
416
417let plugins: Array<Plugin> = [];
418const pluginStateMapper = new StateMapper(() => plugins);
419
420async function setup() {
421    const _plugins: Array<Plugin> = [];
422    try {
423        // 加载插件
424        const pluginsPaths = await readDir(pathConst.pluginPath);
425        for (let i = 0; i < pluginsPaths.length; ++i) {
426            const _pluginUrl = pluginsPaths[i];
427
428            if (_pluginUrl.isFile() && _pluginUrl.name.endsWith('.js')) {
429                const funcCode = await readFile(_pluginUrl.path, 'utf8');
430                const plugin = new Plugin(funcCode, _pluginUrl.path);
431                const _pluginIndex = _plugins.findIndex(
432                    p => p.hash === plugin.hash,
433                );
434                if (_pluginIndex !== -1) {
435                    // 重复插件,直接忽略
436                    return;
437                }
438                plugin.hash !== '' && _plugins.push(plugin);
439            }
440        }
441
442        plugins = _plugins;
443        pluginStateMapper.notify();
444    } catch (e: any) {
445        ToastAndroid.show(
446            `插件初始化失败:${e?.message ?? e}`,
447            ToastAndroid.LONG,
448        );
449        errorLog('插件初始化失败', e?.message);
450        throw e;
451    }
452}
453
454// 安装插件
455async function installPlugin(pluginPath: string) {
456    if (pluginPath.endsWith('.js') && (await exists(pluginPath))) {
457        const funcCode = await readFile(pluginPath, 'utf8');
458        const plugin = new Plugin(funcCode, pluginPath);
459        const _pluginIndex = plugins.findIndex(p => p.hash === plugin.hash);
460        if (_pluginIndex !== -1) {
461            return;
462        }
463        if (plugin.hash !== '') {
464            const fn = nanoid();
465            const _pluginPath = `${pathConst.pluginPath}${fn}.js`;
466            await copyFile(pluginPath, _pluginPath);
467            plugin.path = _pluginPath;
468            plugins = plugins.concat(plugin);
469            pluginStateMapper.notify();
470        }
471    }
472}
473
474/** 卸载插件 */
475async function uninstallPlugin(hash: string) {
476    const targetIndex = plugins.findIndex(_ => _.hash === hash);
477    if (targetIndex !== -1) {
478        try {
479            await unlink(plugins[targetIndex].path);
480            plugins = plugins.filter(_ => _.hash !== hash);
481            pluginStateMapper.notify();
482        } catch {}
483    }
484}
485
486function getByMedia(mediaItem: ICommon.IMediaBase) {
487    return getByName(mediaItem.platform);
488}
489
490function getByHash(hash: string) {
491    return plugins.find(_ => _.hash === hash);
492}
493
494function getByName(name: string) {
495    return plugins.find(_ => _.name === name);
496}
497
498function getValidPlugins() {
499    return plugins.filter(_ => _.state === 'enabled');
500}
501
502const PluginManager = {
503    setup,
504    installPlugin,
505    uninstallPlugin,
506    getByMedia,
507    getByHash,
508    getByName,
509    getValidPlugins,
510    usePlugins: pluginStateMapper.useMappedState,
511};
512
513export default PluginManager;
514