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