xref: /MusicFree/src/core/download.ts (revision 6704747af84cebd842b258efac7143542722fac5)
1import {internalSerialzeKey, internalSymbolKey} from '@/constants/commonConst';
2import pathConst from '@/constants/pathConst';
3import {checkAndCreateDir} from '@/utils/fileUtils';
4import {isSameMediaItem} from '@/utils/mediaItem';
5import StateMapper from '@/utils/stateMapper';
6import {getStorage, setStorage} from '@/utils/storage';
7import deepmerge from 'deepmerge';
8import produce from 'immer';
9import {useEffect, useState} from 'react';
10import {
11  exists,
12  unlink,
13  mkdir,
14  downloadFile,
15  readDir,
16  read,
17  readFile,
18} from 'react-native-fs';
19import Toast from 'react-native-toast-message';
20import Config from './config';
21import MediaMeta from './mediaMeta';
22import PluginManager from './pluginManager';
23
24interface IDownloadMusicOptions {
25  musicItem: IMusic.IMusicItem;
26  filename: string;
27  jobId?: number;
28}
29// todo: 直接把下载信息写在meta里面就好了
30/** 已下载 */
31let downloadedMusic: IMusic.IMusicItem[] = [];
32/** 下载中 */
33let downloadingMusicQueue: IDownloadMusicOptions[] = [];
34/** 队列中 */
35let pendingMusicQueue: IDownloadMusicOptions[] = [];
36
37/** 进度 */
38let downloadingProgress: Record<string, {progress: number; size: number}> = {};
39
40const downloadedStateMapper = new StateMapper(() => downloadedMusic);
41const downloadingQueueStateMapper = new StateMapper(
42  () => downloadingMusicQueue,
43);
44const pendingMusicQueueStateMapper = new StateMapper(() => pendingMusicQueue);
45const downloadingProgressStateMapper = new StateMapper(
46  () => downloadingProgress,
47);
48
49/** 从待下载中移除 */
50function removeFromPendingQueue(item: IDownloadMusicOptions) {
51  pendingMusicQueue = pendingMusicQueue.filter(
52    _ => !isSameMediaItem(_.musicItem, item.musicItem),
53  );
54  pendingMusicQueueStateMapper.notify();
55}
56
57/** 从下载中队列移除 */
58function removeFromDownloadingQueue(item: IDownloadMusicOptions) {
59  downloadingMusicQueue = downloadingMusicQueue.filter(
60    _ => !isSameMediaItem(_.musicItem, item.musicItem),
61  );
62  downloadingQueueStateMapper.notify();
63}
64
65/** 防止高频同步 */
66let progressNotifyTimer: any = null;
67function startNotifyProgress() {
68  if (progressNotifyTimer) {
69    return;
70  }
71
72  progressNotifyTimer = setTimeout(() => {
73    progressNotifyTimer = null;
74    downloadingProgressStateMapper.notify();
75    startNotifyProgress();
76  }, 400);
77}
78
79function stopNotifyProgress() {
80  if (progressNotifyTimer) {
81    clearInterval(progressNotifyTimer);
82  }
83  progressNotifyTimer = null;
84}
85
86/** 根据文件名解析 */
87function parseFilename(fn: string): IMusic.IMusicItemBase | null {
88  const data = fn.slice(0, fn.lastIndexOf('.')).split('@');
89  const [platform, id, title, artist] = data;
90  if (!platform || !id) {
91    return null;
92  }
93  return {
94    id,
95    platform,
96    title,
97    artist,
98  };
99}
100
101/** 生成下载文件名 */
102function generateFilename(musicItem: IMusic.IMusicItem) {
103  return (
104    `${musicItem.platform}@${musicItem.id}@${musicItem.title}@${musicItem.artist}`.slice(
105      0,
106      200,
107    ) + '.mp3'
108  );
109}
110
111/** todo 可以配置一个说明文件 */
112async function loadLocalJson(dirBase: string) {
113  const jsonPath = dirBase + 'data.json';
114  if (await exists(jsonPath)) {
115    try {
116      const result = await readFile(jsonPath, 'utf8');
117      return JSON.parse(result);
118    } catch {
119      return {};
120    }
121  }
122  return {};
123}
124
125/** 初始化 */
126async function setupDownload() {
127  await checkAndCreateDir(pathConst.downloadPath);
128  // const jsonData = await loadLocalJson(pathConst.downloadPath);
129
130  const newDownloadedData: Record<string, IMusic.IMusicItem> = {};
131  const downloads = await readDir(pathConst.downloadPath);
132  downloadedMusic = [];
133
134  for (let i = 0; i < downloads.length; ++i) {
135    const data = parseFilename(downloads[i].name);
136    if (data) {
137      const platform = data?.platform;
138      const id = data?.id;
139      if (platform && id) {
140        const mi = MediaMeta.get(data) ?? {};
141        mi.id = id;
142        mi.platform = platform;
143        mi.title = mi.title ?? data.title;
144        mi.artist = mi.artist ?? data.artist;
145        mi[internalSymbolKey] = {
146          localPath: downloads[i].path,
147        };
148        downloadedMusic.push(mi as IMusic.IMusicItem);
149      }
150    }
151  }
152  downloadedStateMapper.notify();
153  // 去掉冗余数据
154  setStorage('download-music', newDownloadedData);
155}
156
157let maxDownload = 3;
158/** 从队列取出下一个要下载的 */
159async function downloadNext() {
160  // todo 最大同时下载3个,可设置
161  if (
162    downloadingMusicQueue.length >= maxDownload ||
163    pendingMusicQueue.length === 0
164  ) {
165    return;
166  }
167  const nextItem = pendingMusicQueue[0];
168  const musicItem = nextItem.musicItem;
169  let url = musicItem.url;
170  let headers = musicItem.headers;
171  removeFromPendingQueue(nextItem);
172  downloadingMusicQueue = produce(downloadingMusicQueue, draft => {
173    draft.push(nextItem);
174  });
175  downloadingQueueStateMapper.notify();
176  if (!url || !url?.startsWith('http')) {
177    // 插件播放
178    const plugin = PluginManager.getByName(musicItem.platform);
179    if (plugin) {
180      try {
181        const data = await plugin.methods.getMusicTrack(musicItem);
182        url = data?.url;
183        headers = data?.headers;
184      } catch {
185        /** 无法下载,跳过 */
186        removeFromDownloadingQueue(nextItem);
187        return;
188      }
189    }
190  }
191
192  downloadNext();
193  const {promise, jobId} = downloadFile({
194    fromUrl: url ?? '',
195    toFile: pathConst.downloadPath + nextItem.filename,
196    headers: headers,
197    background: true,
198    begin(res) {
199      downloadingProgress = produce(downloadingProgress, _ => {
200        _[nextItem.filename] = {
201          progress: 0,
202          size: res.contentLength,
203        };
204      });
205      startNotifyProgress();
206    },
207    progress(res) {
208      downloadingProgress = produce(downloadingProgress, _ => {
209        _[nextItem.filename] = {
210          progress: res.bytesWritten,
211          size: res.contentLength,
212        };
213      });
214    },
215  });
216  nextItem.jobId = jobId;
217  try {
218    await promise;
219    // 下载完成
220    downloadedMusic = produce(downloadedMusic, _ => {
221      if (
222        downloadedMusic.findIndex(_ => isSameMediaItem(musicItem, _)) === -1
223      ) {
224        _.push({
225          ...musicItem,
226          [internalSymbolKey]: {
227            localPath: pathConst.downloadPath + nextItem.filename,
228          },
229        });
230      }
231      return _;
232    });
233    removeFromDownloadingQueue(nextItem);
234    MediaMeta.update({
235      ...musicItem,
236      [internalSerialzeKey]: {
237        downloaded: true,
238        local: {
239          localUrl: pathConst.downloadPath + nextItem.filename,
240        },
241      },
242    });
243    if (downloadingMusicQueue.length === 0) {
244      stopNotifyProgress();
245      Toast.show({
246        text1: '下载完成',
247        position: 'bottom',
248      });
249      downloadingMusicQueue = [];
250      pendingMusicQueue = [];
251      downloadingQueueStateMapper.notify();
252      pendingMusicQueueStateMapper.notify();
253    }
254    delete downloadingProgress[nextItem.filename];
255    downloadedStateMapper.notify();
256    downloadNext();
257  } catch {
258    downloadingMusicQueue = produce(downloadingMusicQueue, _ =>
259      _.filter(item => !isSameMediaItem(item.musicItem, musicItem)),
260    );
261  }
262}
263
264/** 下载音乐 */
265function downloadMusic(musicItems: IMusic.IMusicItem | IMusic.IMusicItem[]) {
266  // 如果已经在下载中
267  if (!Array.isArray(musicItems)) {
268    musicItems = [musicItems];
269  }
270  musicItems = musicItems.filter(
271    musicItem =>
272      pendingMusicQueue.findIndex(_ =>
273        isSameMediaItem(_.musicItem, musicItem),
274      ) === -1 &&
275      downloadingMusicQueue.findIndex(_ =>
276        isSameMediaItem(_.musicItem, musicItem),
277      ) === -1,
278  );
279  const enqueueData = musicItems.map(_ => ({
280    musicItem: _,
281    filename: generateFilename(_),
282  }));
283  if (enqueueData.length) {
284    pendingMusicQueue = pendingMusicQueue.concat(enqueueData);
285    pendingMusicQueueStateMapper.notify();
286    maxDownload = +(Config.get('setting.basic.maxDownload') ?? 3);
287    downloadNext();
288  }
289}
290
291/** 是否下载 */
292function isDownloaded(mi: IMusic.IMusicItem | null) {
293  return mi
294    ? downloadedMusic.findIndex(_ => isSameMediaItem(_, mi)) !== -1
295    : false;
296}
297
298/** 获取下载的音乐 */
299function getDownloaded(mi: ICommon.IMediaBase | null) {
300  return mi ? downloadedMusic.find(_ => isSameMediaItem(_, mi)) : null;
301}
302
303/** 移除下载的文件 */
304async function removeDownloaded(mi: IMusic.IMusicItem) {
305  const localPath = getDownloaded(mi)?.[internalSymbolKey]?.localPath;
306  if (localPath) {
307    await unlink(localPath);
308    downloadedMusic = downloadedMusic.filter(_ => !isSameMediaItem(_, mi));
309    MediaMeta.update(mi, undefined);
310    downloadedStateMapper.notify();
311  }
312}
313
314/** 某个音乐是否被下载-状态 */
315function useIsDownloaded(mi: IMusic.IMusicItem | null) {
316  if (!mi) {
317    return false;
318  }
319  const downloadedMusicState = downloadedStateMapper.useMappedState();
320  const [downloaded, setDownloaded] = useState<boolean>(isDownloaded(mi));
321  useEffect(() => {
322    setDownloaded(
323      downloadedMusicState.findIndex(_ => isSameMediaItem(mi, _)) !== -1,
324    );
325  }, [downloadedMusicState, mi]);
326  return downloaded;
327}
328
329const Download = {
330  downloadMusic,
331  setup: setupDownload,
332  useDownloadedMusic: downloadedStateMapper.useMappedState,
333  useDownloadingMusic: downloadingQueueStateMapper.useMappedState,
334  usePendingMusic: pendingMusicQueueStateMapper.useMappedState,
335  useDownloadingProgress: downloadingProgressStateMapper.useMappedState,
336  isDownloaded,
337  useIsDownloaded,
338  getDownloaded,
339  removeDownloaded,
340};
341
342export default Download;
343