xref: /MusicFree/src/core/pluginManager.ts (revision 3d6d339a34b2efd730ef19aa749985e7e9221f96)
1import RNFS, {exists, readFile, writeFile} from 'react-native-fs';
2import CryptoJs from 'crypto-js';
3import dayjs from 'dayjs';
4import axios from 'axios';
5import {useEffect, useState} from 'react';
6import {ToastAndroid} from 'react-native';
7import pathConst from '@/constants/pathConst';
8import {satisfies} from 'compare-versions';
9import DeviceInfo from 'react-native-device-info';
10import StateMapper from '@/utils/stateMapper';
11import MediaMetaManager from './mediaMetaManager';
12import {nanoid} from 'nanoid';
13import {errorLog, trace} from '../utils/log';
14import Cache from './cache';
15import { isSameMediaItem } from '@/utils/mediaItem';
16
17axios.defaults.timeout = 1500;
18
19const sha256 = CryptoJs.SHA256;
20
21enum PluginStateCode {
22  /** 版本不匹配 */
23  VersionNotMatch = 'VERSION NOT MATCH',
24  /** 插件不完整 */
25  NotComplete = 'NOT COMPLETE',
26  /** 无法解析 */
27  CannotParse = 'CANNOT PARSE',
28}
29export class Plugin {
30  /** 插件名 */
31  public name: string;
32  /** 插件的hash,作为唯一id */
33  public hash: string;
34  /** 插件状态:激活、关闭、错误 */
35  public state: 'enabled' | 'disabled' | 'error';
36  /** 插件支持的搜索类型 */
37  public supportedSearchType?: string;
38  /** 插件状态信息 */
39  public stateCode?: PluginStateCode;
40  /** 插件的实例 */
41  public instance: IPlugin.IPluginInstance;
42
43  constructor(funcCode: string, pluginPath: string) {
44    this.state = 'enabled';
45    let _instance: IPlugin.IPluginInstance;
46    try {
47      _instance = Function(`
48      'use strict';
49      try {
50        return ${funcCode};
51      } catch(e) {
52        return null;
53      }
54    `)()({CryptoJs, axios, dayjs});
55
56      this.checkValid(_instance);
57    } catch (e: any) {
58      this.state = 'error';
59      this.stateCode = PluginStateCode.CannotParse;
60      if (e?.stateCode) {
61        this.stateCode = e.stateCode;
62      }
63      errorLog(`${pluginPath}插件无法解析 `, {
64        stateCode: this.stateCode,
65        message: e?.message,
66        stack: e?.stack,
67      });
68      _instance = e?.instance ?? {
69        _path: '',
70        platform: '',
71        appVersion: '',
72        async getMusicTrack() {
73          return null;
74        },
75        async search() {
76          return {};
77        },
78        async getAlbumInfo() {
79          return null;
80        },
81      };
82    }
83    this.instance = _instance;
84    this.instance._path = pluginPath;
85    this.name = _instance.platform;
86    if (this.instance.platform === '') {
87      this.hash = '';
88    } else {
89      this.hash = sha256(funcCode).toString();
90    }
91  }
92
93  private checkValid(_instance: IPlugin.IPluginInstance) {
94    // 总不会一个都没有吧
95    const keys: Array<keyof IPlugin.IPluginInstance> = [
96      'getAlbumInfo',
97      'search',
98      'getMusicTrack',
99    ];
100    if (keys.every(k => !_instance[k])) {
101      throw {
102        instance: _instance,
103        stateCode: PluginStateCode.NotComplete,
104      };
105    }
106    /** 版本号校验 */
107    if (
108      _instance.appVersion &&
109      !satisfies(DeviceInfo.getVersion(), _instance.appVersion)
110    ) {
111      throw {
112        instance: _instance,
113        stateCode: PluginStateCode.VersionNotMatch,
114      };
115    }
116    return true;
117  }
118}
119
120class PluginManager {
121  private plugins: Array<Plugin> = [];
122  loading: boolean = true;
123  /** 插件安装位置 */
124  pluginPath: string = pathConst.pluginPath;
125  constructor(private onSetup: () => void) {}
126
127  private loadPlugin(funcCode: string, pluginPath: string) {
128    const plugin = new Plugin(funcCode, pluginPath);
129    const _pluginIndex = this.plugins.findIndex(p => p.hash === plugin.hash);
130    if (_pluginIndex !== -1) {
131      // 有重复的了,直接忽略
132      return;
133    }
134    plugin.hash !== '' && this.plugins.push(plugin);
135  }
136
137  getPlugins() {
138    return this.plugins;
139  }
140
141  getValidPlugins() {
142    return this.plugins.filter(_ => _.state === 'enabled');
143  }
144
145  getPlugin(platform: string) {
146    return this.plugins.find(
147      _ => _.instance.platform === platform && _.state === 'enabled',
148    );
149  }
150
151  getPluginByPlatform(platform: string) {
152    return this.plugins.filter(_ => _.name === platform);
153  }
154
155  getPluginByHash(hash: string) {
156    return this.plugins.find(_ => _.hash === hash);
157  }
158
159  async setupPlugins() {
160    this.loading = true;
161    this.plugins = [];
162    try {
163      this.loading = false;
164      // 加载插件
165      const pluginsPaths = await RNFS.readDir(pathConst.pluginPath);
166      for (let i = 0; i < pluginsPaths.length; ++i) {
167        const _plugin = pluginsPaths[i];
168
169        if (_plugin.isFile() && _plugin.name.endsWith('.js')) {
170          const funcCode = await RNFS.readFile(_plugin.path, 'utf8');
171          this.loadPlugin(funcCode, _plugin.path);
172        }
173      }
174      this.onSetup();
175      this.loading = false;
176    } catch (e: any) {
177      ToastAndroid.show(`插件初始化失败:${e?.message ?? e}`, ToastAndroid.LONG);
178      this.loading = false;
179      throw e;
180    }
181  }
182}
183
184const pluginManager = new PluginManager(() => {
185  pluginStateMapper?.notify?.();
186});
187const pluginStateMapper = new StateMapper(() => pluginManager?.getPlugins?.());
188
189// function usePlugins() {
190//   const [plugins, setPlugins] = useState(pluginManager.getValidPlugins());
191
192//   useEffect(() => {
193//     if (pluginManager.loading === false) {
194//       setPlugins(pluginManager.getValidPlugins());
195//     }
196//   }, [pluginManager.loading]);
197
198//   return plugins;
199// }
200
201/** 封装的插件方法 */
202const pluginMethod = {
203  async getLyric(
204    musicItem: IMusic.IMusicItem,
205    from?: IMusic.IMusicItem,
206  ): Promise<string | undefined> {
207    // 1. 额外存储的meta信息
208    const meta = MediaMetaManager.getMediaMeta(musicItem) ?? {};
209    // 有关联歌词
210    if (meta.associatedLrc) {
211      if (
212        isSameMediaItem(musicItem, from) ||
213        isSameMediaItem(meta.associatedLrc, musicItem)
214      ) {
215        // 形成了环 只把自己断开
216        await MediaMetaManager.updateMediaMeta(musicItem, {
217          associatedLrc: undefined,
218        });
219        return;
220      }
221      const result = await pluginMethod.getLyric(
222        meta.associatedLrc as IMusic.IMusicItem,
223        from ?? musicItem,
224      );
225      if (result) {
226        return result;
227      }
228    }
229    const cache = Cache.get(musicItem);
230    // 优先级:meta中、当前歌曲最新的rawlrc、缓存中的rawlrc
231    if (meta?.rawLrc || musicItem.rawLrc || cache?.rawLrc) {
232      return meta.rawLrc ?? musicItem.rawLrc ?? cache?.rawLrc;
233    }
234    // 本地文件中的lrc
235    const localLrc = meta.localLrc ?? cache?.localLrc;
236    if (localLrc && (await exists(localLrc))) {
237      return await readFile(localLrc, 'utf8');
238    }
239
240    let lrcUrl: string | undefined = meta?.lrc ?? musicItem.lrc ?? cache?.lrc;
241    let rawLrc: string | undefined;
242    // mediaItem中自带的lrcurl
243    if (lrcUrl) {
244      try {
245        // 需要超时时间 axios timeout 但是没生效
246        rawLrc = (await axios.get(lrcUrl)).data;
247      } catch {
248        lrcUrl = undefined;
249      }
250    }
251    if (!lrcUrl) {
252      // 从插件中获得
253      const plugin = pluginManager.getPlugin(musicItem.platform);
254      try {
255        const lrcSource = await plugin?.instance?.getLyric?.(musicItem);
256        rawLrc = lrcSource?.rawLrc;
257        lrcUrl = lrcSource?.lrc;
258      } catch (e: any) {
259        trace('插件获取失败', e?.message, 'error');
260      }
261    }
262    if (rawLrc || lrcUrl) {
263      const filename = `${pathConst.lrcCachePath}${nanoid()}.lrc`;
264      if (rawLrc) {
265        await writeFile(filename, rawLrc, 'utf8');
266        // todo 写入缓存 应该是internaldata
267        Cache.update(musicItem, {
268          localLrc: filename,
269        });
270        // 如果有用户定制化的信息,就写入持久存储中
271        if (meta) {
272          MediaMetaManager.updateMediaMeta(musicItem, {
273            localLrc: filename,
274          });
275        }
276
277        return rawLrc;
278      }
279      if (lrcUrl) {
280        try {
281          const content = (await axios.get(lrcUrl)).data;
282          await writeFile(filename, content, 'utf8');
283          Cache.update(musicItem, {
284            localLrc: filename,
285          });
286          if (meta) {
287            MediaMetaManager.updateMediaMeta(musicItem, {
288              localLrc: filename,
289              lrc: lrcUrl,
290            });
291          }
292
293          return content;
294        } catch {}
295      }
296    }
297  },
298};
299
300const usePlugins = pluginStateMapper.useMappedState;
301
302export {pluginManager, usePlugins, pluginMethod};
303