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