xref: /MusicFree/src/core/pluginManager.ts (revision cfa0fc0757dad620cd0b0533a949d86b17086d32)
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 {
23    CacheControl,
24    internalSerialzeKey,
25    internalSymbolKey,
26} from '@/constants/commonConst';
27import Download from './download';
28import delay from '@/utils/delay';
29import * as cheerio from 'cheerio';
30
31axios.defaults.timeout = 1500;
32
33const sha256 = CryptoJs.SHA256;
34
35export enum PluginStateCode {
36    /** 版本不匹配 */
37    VersionNotMatch = 'VERSION NOT MATCH',
38    /** 无法解析 */
39    CannotParse = 'CANNOT PARSE',
40}
41
42export class Plugin {
43    /** 插件名 */
44    public name: string;
45    /** 插件的hash,作为唯一id */
46    public hash: string;
47    /** 插件状态:激活、关闭、错误 */
48    public state: 'enabled' | 'disabled' | 'error';
49    /** 插件支持的搜索类型 */
50    public supportedSearchType?: string;
51    /** 插件状态信息 */
52    public stateCode?: PluginStateCode;
53    /** 插件的实例 */
54    public instance: IPlugin.IPluginInstance;
55    /** 插件路径 */
56    public path: string;
57    /** 插件方法 */
58    public methods: PluginMethods;
59
60    constructor(funcCode: string, pluginPath: string) {
61        this.state = 'enabled';
62        let _instance: IPlugin.IPluginInstance;
63        try {
64            // eslint-disable-next-line no-new-func
65            _instance = Function(`
66      'use strict';
67      try {
68        return ${funcCode};
69      } catch(e) {
70        return null;
71      }
72    `)()({CryptoJs, axios, dayjs, cheerio});
73            this.checkValid(_instance);
74        } catch (e: any) {
75            this.state = 'error';
76            this.stateCode = PluginStateCode.CannotParse;
77            if (e?.stateCode) {
78                this.stateCode = e.stateCode;
79            }
80            errorLog(`${pluginPath}插件无法解析 `, {
81                stateCode: this.stateCode,
82                message: e?.message,
83                stack: e?.stack,
84            });
85            _instance = e?.instance ?? {
86                _path: '',
87                platform: '',
88                appVersion: '',
89                async getMediaSource() {
90                    return null;
91                },
92                async search() {
93                    return {};
94                },
95                async getAlbumInfo() {
96                    return null;
97                },
98            };
99        }
100        this.instance = _instance;
101        this.path = pluginPath;
102        this.name = _instance.platform;
103        if (this.instance.platform === '') {
104            this.hash = '';
105        } else {
106            this.hash = sha256(funcCode).toString();
107        }
108
109        // 放在最后
110        this.methods = new PluginMethods(this);
111    }
112
113    private checkValid(_instance: IPlugin.IPluginInstance) {
114        /** 版本号校验 */
115        if (
116            _instance.appVersion &&
117            !satisfies(DeviceInfo.getVersion(), _instance.appVersion)
118        ) {
119            throw {
120                instance: _instance,
121                stateCode: PluginStateCode.VersionNotMatch,
122            };
123        }
124        return true;
125    }
126}
127
128/** 有缓存等信息 */
129class PluginMethods implements IPlugin.IPluginInstanceMethods {
130    private plugin;
131    constructor(plugin: Plugin) {
132        this.plugin = plugin;
133    }
134    /** 搜索 */
135    async search<T extends ICommon.SupportMediaType>(
136        query: string,
137        page: number,
138        type: T,
139    ): Promise<IPlugin.ISearchResult<T>> {
140        if (!this.plugin.instance.search) {
141            return {
142                isEnd: true,
143                data: [],
144            };
145        }
146
147        const result =
148            (await this.plugin.instance.search(query, page, type)) ?? {};
149        if (Array.isArray(result.data)) {
150            result.data.forEach(_ => {
151                resetMediaItem(_, this.plugin.name);
152            });
153            return {
154                isEnd: result.isEnd ?? true,
155                data: result.data,
156            };
157        }
158        return {
159            isEnd: true,
160            data: [],
161        };
162    }
163
164    /** 获取真实源 */
165    async getMediaSource(
166        musicItem: IMusic.IMusicItemBase,
167        retryCount = 1,
168    ): Promise<IPlugin.IMediaSourceResult> {
169        // 1. 本地搜索 其实直接读mediameta就好了
170        const localPath =
171            musicItem?.[internalSymbolKey]?.localPath ??
172            Download.getDownloaded(musicItem)?.[internalSymbolKey]?.localPath;
173        if (localPath && (await exists(localPath))) {
174            trace('播放', '本地播放');
175            return {
176                url: localPath,
177            };
178        }
179        // 2. 缓存播放
180        // todo: 无网络情况下强制使用缓存播放 no-cache: 无网络情况下使用cache
181        const mediaCache = Cache.get(musicItem);
182        if (
183            mediaCache &&
184            mediaCache?.url &&
185            mediaCache.cache === CacheControl.Cache
186        ) {
187            trace('播放', '缓存播放');
188            return {
189                url: mediaCache.url,
190                headers: mediaCache.headers,
191                userAgent:
192                    mediaCache.userAgent ?? mediaCache.headers?.['user-agent'],
193            };
194        }
195        // 3. 插件解析
196        if (!this.plugin.instance.getMediaSource) {
197            return {url: musicItem.url};
198        }
199        try {
200            const {url, headers, cacheControl} =
201                (await this.plugin.instance.getMediaSource(musicItem)) ?? {};
202            if (!url) {
203                throw new Error();
204            }
205            trace('播放', '插件播放');
206            const result = {
207                url,
208                headers,
209                userAgent: headers?.['user-agent'],
210                cacheControl: cacheControl ?? CacheControl.Cache,
211            } as IPlugin.IMediaSourceResult;
212
213            if (cacheControl !== CacheControl.NoStore) {
214                Cache.update(musicItem, result);
215            }
216
217            return result;
218        } catch (e: any) {
219            if (retryCount > 0) {
220                await delay(150);
221                return this.getMediaSource(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, {timeout: 1500})).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, {timeout: 1500})).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    /** 导入歌单 */
415    async importMusicSheet(urlLike: string): Promise<IMusic.IMusicItem[]> {
416        try {
417            const result =
418                (await this.plugin.instance?.importMusicSheet?.(urlLike)) ?? [];
419            result.forEach(_ => resetMediaItem(_, this.plugin.name));
420            return result;
421        } catch {
422            return [];
423        }
424    }
425    /** 导入单曲 */
426    async importMusicItem(urlLike: string): Promise<IMusic.IMusicItem | null> {
427        try {
428            const result = await this.plugin.instance?.importMusicItem?.(
429                urlLike,
430            );
431            if (!result) {
432                throw new Error();
433            }
434            resetMediaItem(result, this.plugin.name);
435            return result;
436        } catch {
437            return null;
438        }
439    }
440}
441
442let plugins: Array<Plugin> = [];
443const pluginStateMapper = new StateMapper(() => plugins);
444
445async function setup() {
446    const _plugins: Array<Plugin> = [];
447    try {
448        // 加载插件
449        const pluginsPaths = await readDir(pathConst.pluginPath);
450        for (let i = 0; i < pluginsPaths.length; ++i) {
451            const _pluginUrl = pluginsPaths[i];
452
453            if (_pluginUrl.isFile() && _pluginUrl.name.endsWith('.js')) {
454                const funcCode = await readFile(_pluginUrl.path, 'utf8');
455                const plugin = new Plugin(funcCode, _pluginUrl.path);
456                const _pluginIndex = _plugins.findIndex(
457                    p => p.hash === plugin.hash,
458                );
459                if (_pluginIndex !== -1) {
460                    // 重复插件,直接忽略
461                    return;
462                }
463                plugin.hash !== '' && _plugins.push(plugin);
464            }
465        }
466
467        plugins = _plugins;
468        pluginStateMapper.notify();
469    } catch (e: any) {
470        ToastAndroid.show(
471            `插件初始化失败:${e?.message ?? e}`,
472            ToastAndroid.LONG,
473        );
474        errorLog('插件初始化失败', e?.message);
475        throw e;
476    }
477}
478
479// 安装插件
480async function installPlugin(pluginPath: string) {
481    // let checkPath = decodeURIComponent(pluginPath);
482    // trace(checkPath, await exists(checkPath));
483    // trace(pluginPath, await exists(pluginPath));
484    // trace(pluginPath.substring(7), await exists(pluginPath.substring(7)));
485    // trace(checkPath.substring(7), await exists(checkPath.substring(7)));
486    if (pluginPath.endsWith('.js')) {
487        const funcCode = await readFile(pluginPath, 'utf8');
488        const plugin = new Plugin(funcCode, pluginPath);
489        const _pluginIndex = plugins.findIndex(p => p.hash === plugin.hash);
490        if (_pluginIndex !== -1) {
491            throw new Error('插件已安装');
492        }
493        if (plugin.hash !== '') {
494            const fn = nanoid();
495            const _pluginPath = `${pathConst.pluginPath}${fn}.js`;
496            await copyFile(pluginPath, _pluginPath);
497            plugin.path = _pluginPath;
498            plugins = plugins.concat(plugin);
499            pluginStateMapper.notify();
500            return;
501        }
502        throw new Error('插件无法解析');
503    }
504    throw new Error('插件不存在');
505}
506
507/** 卸载插件 */
508async function uninstallPlugin(hash: string) {
509    const targetIndex = plugins.findIndex(_ => _.hash === hash);
510    if (targetIndex !== -1) {
511        try {
512            await unlink(plugins[targetIndex].path);
513            plugins = plugins.filter(_ => _.hash !== hash);
514            pluginStateMapper.notify();
515        } catch {}
516    }
517}
518
519function getByMedia(mediaItem: ICommon.IMediaBase) {
520    return getByName(mediaItem.platform);
521}
522
523function getByHash(hash: string) {
524    return plugins.find(_ => _.hash === hash);
525}
526
527function getByName(name: string) {
528    return plugins.find(_ => _.name === name);
529}
530
531function getValidPlugins() {
532    return plugins.filter(_ => _.state === 'enabled');
533}
534
535const PluginManager = {
536    setup,
537    installPlugin,
538    uninstallPlugin,
539    getByMedia,
540    getByHash,
541    getByName,
542    getValidPlugins,
543    usePlugins: pluginStateMapper.useMappedState,
544};
545
546export default PluginManager;
547