xref: /MusicFree/src/core/pluginManager.ts (revision 927dbe935e5ed3130d85ec27a6ac3efd1a163ad1)
1import RNFS, {
2  copyFile,
3  exists,
4  moveFile,
5  readDir,
6  readFile,
7  unlink,
8  writeFile,
9} from 'react-native-fs';
10import CryptoJs from 'crypto-js';
11import dayjs from 'dayjs';
12import axios from 'axios';
13import {useEffect, useState} from 'react';
14import {ToastAndroid} from 'react-native';
15import pathConst from '@/constants/pathConst';
16import {satisfies} from 'compare-versions';
17import DeviceInfo from 'react-native-device-info';
18import StateMapper from '@/utils/stateMapper';
19import MediaMeta from './mediaMeta';
20import {nanoid} from 'nanoid';
21import {errorLog, trace} from '../utils/log';
22import Cache from './cache';
23import {isSameMediaItem, resetMediaItem} from '@/utils/mediaItem';
24import {internalSerialzeKey, internalSymbolKey} from '@/constants/commonConst';
25import Download from './download';
26import delay from '@/utils/delay';
27
28axios.defaults.timeout = 1500;
29
30const sha256 = CryptoJs.SHA256;
31
32enum PluginStateCode {
33  /** 版本不匹配 */
34  VersionNotMatch = 'VERSION NOT MATCH',
35  /** 插件不完整 */
36  NotComplete = 'NOT COMPLETE',
37  /** 无法解析 */
38  CannotParse = 'CANNOT PARSE',
39}
40
41export class Plugin {
42  /** 插件名 */
43  public name: string;
44  /** 插件的hash,作为唯一id */
45  public hash: string;
46  /** 插件状态:激活、关闭、错误 */
47  public state: 'enabled' | 'disabled' | 'error';
48  /** 插件支持的搜索类型 */
49  public supportedSearchType?: string;
50  /** 插件状态信息 */
51  public stateCode?: PluginStateCode;
52  /** 插件的实例 */
53  public instance: IPlugin.IPluginInstance;
54  /** 插件路径 */
55  public path: string;
56  /** 插件方法 */
57  public methods: PluginMethods;
58
59  constructor(funcCode: string, pluginPath: string) {
60    this.state = 'enabled';
61    let _instance: IPlugin.IPluginInstance;
62    try {
63      _instance = Function(`
64      'use strict';
65      try {
66        return ${funcCode};
67      } catch(e) {
68        return null;
69      }
70    `)()({CryptoJs, axios, dayjs});
71
72      this.checkValid(_instance);
73    } catch (e: any) {
74      this.state = 'error';
75      this.stateCode = PluginStateCode.CannotParse;
76      if (e?.stateCode) {
77        this.stateCode = e.stateCode;
78      }
79      errorLog(`${pluginPath}插件无法解析 `, {
80        stateCode: this.stateCode,
81        message: e?.message,
82        stack: e?.stack,
83      });
84      _instance = e?.instance ?? {
85        _path: '',
86        platform: '',
87        appVersion: '',
88        async getMusicTrack() {
89          return null;
90        },
91        async search() {
92          return {};
93        },
94        async getAlbumInfo() {
95          return null;
96        },
97      };
98    }
99    this.instance = _instance;
100    this.path = pluginPath;
101    this.name = _instance.platform;
102    if (this.instance.platform === '') {
103      this.hash = '';
104    } else {
105      this.hash = sha256(funcCode).toString();
106    }
107
108    // 放在最后
109    this.methods = new PluginMethods(this);
110  }
111
112  private checkValid(_instance: IPlugin.IPluginInstance) {
113    // 总不会一个都没有吧
114    const keys: Array<keyof IPlugin.IPluginInstance> = [
115      'getAlbumInfo',
116      'search',
117      'getMusicTrack',
118    ];
119    if (keys.every(k => !_instance[k])) {
120      throw {
121        instance: _instance,
122        stateCode: PluginStateCode.NotComplete,
123      };
124    }
125    /** 版本号校验 */
126    if (
127      _instance.appVersion &&
128      !satisfies(DeviceInfo.getVersion(), _instance.appVersion)
129    ) {
130      throw {
131        instance: _instance,
132        stateCode: PluginStateCode.VersionNotMatch,
133      };
134    }
135    return true;
136  }
137}
138
139/** 有缓存等信息 */
140class PluginMethods implements IPlugin.IPluginInstanceMethods {
141  private plugin;
142  constructor(plugin: Plugin) {
143    this.plugin = plugin;
144  }
145  /** 搜索 */
146  async search<T extends ICommon.SupportMediaType>(
147    query: string,
148    page: number,
149    type: T,
150  ): Promise<IPlugin.ISearchResult<T>> {
151    if (!this.plugin.instance.search) {
152      return {
153        isEnd: true,
154        data: [],
155      };
156    }
157
158    const result = (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      return {
185        url: localPath,
186      };
187    }
188    // 2. 缓存播放
189    const mediaCache = Cache.get(musicItem);
190    if (mediaCache && mediaCache?.url) {
191      return {
192        url: mediaCache.url,
193        headers: mediaCache.headers,
194        userAgent: mediaCache.userAgent ?? mediaCache.headers?.['user-agent'],
195      };
196    }
197    // 3. 插件解析
198    if (!this.plugin.instance.getMusicTrack) {
199      return {url: musicItem.url};
200    }
201    try {
202      const {url, headers} =
203        (await this.plugin.instance.getMusicTrack(musicItem)) ?? {};
204      if (!url) {
205        throw new Error();
206      }
207      const result = {
208        url,
209        headers,
210        userAgent: headers?.['user-agent'],
211      };
212
213      Cache.update(musicItem, result);
214      return result;
215    } catch (e: any) {
216      if (retryCount > 0) {
217        await delay(150);
218        return this.getMusicTrack(musicItem, --retryCount);
219      }
220      errorLog('获取真实源失败', e?.message);
221      throw e;
222    }
223  }
224
225  /** 获取音乐详情 */
226  async getMusicInfo(
227    musicItem: ICommon.IMediaBase,
228  ): Promise<IMusic.IMusicItem | null> {
229    if (!this.plugin.instance.getMusicInfo) {
230      return musicItem as IMusic.IMusicItem;
231    }
232    return (
233      this.plugin.instance.getMusicInfo(
234        resetMediaItem(musicItem, undefined, true),
235      ) ?? musicItem
236    );
237  }
238
239  /** 获取歌词 */
240  async getLyric(
241    musicItem: IMusic.IMusicItemBase,
242    from?: IMusic.IMusicItemBase,
243  ): Promise<ILyric.ILyricSource | null> {
244    // 1.额外存储的meta信息
245    const meta = MediaMeta.get(musicItem);
246    if (meta && meta.associatedLrc) {
247      // 有关联歌词
248      if (
249        isSameMediaItem(musicItem, from) ||
250        isSameMediaItem(meta.associatedLrc, musicItem)
251      ) {
252        // 形成环路,断开当前的环
253        await MediaMeta.update(musicItem, {
254          associatedLrc: undefined,
255        });
256        // 无歌词
257        return null;
258      }
259      // 获取关联歌词
260      const result = await this.getLyric(meta.associatedLrc, from ?? musicItem);
261      if (result) {
262        // 如果有关联歌词,就返回关联歌词,深度优先
263        return result;
264      }
265    }
266    const cache = Cache.get(musicItem);
267    let rawLrc = meta?.rawLrc || musicItem.rawLrc || cache?.rawLrc;
268    let lrcUrl = meta?.lrc || musicItem.lrc || cache?.lrc;
269    // 如果存在文本
270    if (rawLrc) {
271      return {
272        rawLrc,
273        lrc: lrcUrl,
274      };
275    }
276    // 2.本地缓存
277    const localLrc =
278      meta?.[internalSerialzeKey]?.local?.localLrc ||
279      cache?.[internalSerialzeKey]?.local?.localLrc;
280    if (localLrc && (await exists(localLrc))) {
281      rawLrc = await readFile(localLrc, 'utf8');
282      return {
283        rawLrc,
284        lrc: lrcUrl,
285      };
286    }
287    // 3.优先使用url
288    if (lrcUrl) {
289      try {
290        // 需要超时时间 axios timeout 但是没生效
291        rawLrc = (await axios.get(lrcUrl)).data;
292        return {
293          rawLrc,
294          lrc: lrcUrl,
295        };
296      } catch {
297        lrcUrl = undefined;
298      }
299    }
300    // 4. 如果地址失效
301    if (!lrcUrl) {
302      // 插件获得url
303      try {
304        const lrcSource = await this.plugin.instance?.getLyric?.(
305          resetMediaItem(musicItem, undefined, true),
306        );
307        rawLrc = lrcSource?.rawLrc;
308        lrcUrl = lrcSource?.lrc;
309      } catch (e: any) {
310        trace('插件获取歌词失败', e?.message, 'error');
311      }
312    }
313    // 5. 最后一次请求
314    if (rawLrc || lrcUrl) {
315      const filename = `${pathConst.lrcCachePath}${nanoid()}.lrc`;
316      if (lrcUrl) {
317        try {
318          rawLrc = (await axios.get(lrcUrl)).data;
319        } catch {}
320      }
321      if (rawLrc) {
322        await writeFile(filename, rawLrc, 'utf8');
323        // 写入缓存
324        Cache.update(musicItem, [
325          [`${internalSerialzeKey}.local.localLrc`, filename],
326        ]);
327        // 如果有meta
328        if (meta) {
329          MediaMeta.update(musicItem, [
330            [`${internalSerialzeKey}.local.localLrc`, filename],
331          ]);
332        }
333        return {
334          rawLrc,
335          lrc: lrcUrl,
336        };
337      }
338    }
339
340    return null;
341  }
342
343  /** 获取歌词文本 */
344  async getLyricText(
345    musicItem: IMusic.IMusicItem,
346  ): Promise<string | undefined> {
347    return (await this.getLyric(musicItem))?.rawLrc;
348  }
349
350  /** 获取专辑信息 */
351  async getAlbumInfo(
352    albumItem: IAlbum.IAlbumItemBase,
353  ): Promise<IAlbum.IAlbumItem | null> {
354    if (!this.plugin.instance.getAlbumInfo) {
355      return {...albumItem, musicList: []};
356    }
357    try {
358      const result = await this.plugin.instance.getAlbumInfo(
359        resetMediaItem(albumItem, undefined, true),
360      );
361      result?.musicList?.forEach(_ => {
362        resetMediaItem(_, this.plugin.name);
363      });
364      return result;
365    } catch {
366      return {...albumItem, musicList: []};
367    }
368  }
369
370  /** 查询作者信息 */
371  async queryArtistWorks<T extends IArtist.ArtistMediaType>(
372    artistItem: IArtist.IArtistItem,
373    page: number,
374    type: T,
375  ): Promise<IPlugin.ISearchResult<T>> {
376    if (!this.plugin.instance.queryArtistWorks) {
377      return {
378        isEnd: true,
379        data: [],
380      };
381    }
382    try {
383      const result = await this.plugin.instance.queryArtistWorks(
384        artistItem,
385        page,
386        type,
387      );
388      if (!result.data) {
389        return {
390          isEnd: true,
391          data: [],
392        };
393      }
394      result.data?.forEach(_ => resetMediaItem(_, this.plugin.name));
395      return {
396        isEnd: result.isEnd ?? true,
397        data: result.data,
398      };
399    } catch (e) {
400      throw e;
401    }
402  }
403}
404let plugins: Array<Plugin> = [];
405const pluginStateMapper = new StateMapper(() => plugins);
406
407async function setup() {
408  const _plugins: Array<Plugin> = [];
409  try {
410    // 加载插件
411    const pluginsPaths = await readDir(pathConst.pluginPath);
412    for (let i = 0; i < pluginsPaths.length; ++i) {
413      const _pluginUrl = pluginsPaths[i];
414
415      if (_pluginUrl.isFile() && _pluginUrl.name.endsWith('.js')) {
416        const funcCode = await readFile(_pluginUrl.path, 'utf8');
417        const plugin = new Plugin(funcCode, _pluginUrl.path);
418        const _pluginIndex = _plugins.findIndex(p => p.hash === plugin.hash);
419        if (_pluginIndex !== -1) {
420          // 重复插件,直接忽略
421          return;
422        }
423        plugin.hash !== '' && _plugins.push(plugin);
424      }
425    }
426
427    plugins = _plugins;
428    pluginStateMapper.notify();
429  } catch (e: any) {
430    ToastAndroid.show(`插件初始化失败:${e?.message ?? e}`, ToastAndroid.LONG);
431    throw e;
432  }
433}
434
435// 安装插件
436async function installPlugin(pluginPath: string) {
437  if (pluginPath.endsWith('.js') && (await exists(pluginPath))) {
438    const funcCode = await readFile(pluginPath, 'utf8');
439    const plugin = new Plugin(funcCode, pluginPath);
440    const _pluginIndex = plugins.findIndex(p => p.hash === plugin.hash);
441    if (_pluginIndex !== -1) {
442      return;
443    }
444    if (plugin.hash !== '') {
445      const fn = nanoid();
446      const _pluginPath = `${pathConst.pluginPath}${fn}.js`;
447      await copyFile(pluginPath, _pluginPath);
448      plugin.path = _pluginPath;
449      plugins = plugins.concat(plugin);
450      pluginStateMapper.notify();
451    }
452  }
453}
454
455/** 卸载插件 */
456async function uninstallPlugin(hash: string) {
457  const targetIndex = plugins.findIndex(_ => _.hash === hash);
458  if (targetIndex !== -1) {
459    try {
460      await unlink(plugins[targetIndex].path);
461      plugins = plugins.filter(_ => _.hash !== hash);
462      pluginStateMapper.notify();
463    } catch {}
464  }
465}
466
467function getByMedia(mediaItem: ICommon.IMediaBase) {
468    return getByName(mediaItem.platform);
469}
470
471function getByHash(hash: string) {
472  return plugins.find(_ => _.hash === hash);
473}
474
475function getByName(name: string) {
476  return plugins.find(_ => _.name === name);
477}
478
479function getValidPlugins() {
480  return plugins.filter(_ => _.state === 'enabled');
481}
482
483
484const PluginManager = {
485    setup,
486    installPlugin,
487    uninstallPlugin,
488    getByMedia,
489    getByHash,
490    getByName,
491    getValidPlugins,
492    usePlugins: pluginStateMapper.useMappedState
493}
494
495export default PluginManager;
496