xref: /MusicFree/src/core/download.ts (revision 8c55a6aa7a01f120247fcb5fabac8ec87ebb57cd)
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.downloadMusicPath);
122    // const jsonData = await loadLocalJson(pathConst.downloadMusicPath);
123
124    const newDownloadedData: Record<string, IMusic.IMusicItem> = {};
125    downloadedMusic = [];
126
127    try {
128        const downloads = await readDir(pathConst.downloadMusicPath);
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.downloadMusicPath + 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:
229                            pathConst.downloadMusicPath + nextItem.filename,
230                    },
231                });
232            }
233            return _;
234        });
235        removeFromDownloadingQueue(nextItem);
236        MediaMeta.update({
237            ...musicItem,
238            [internalSerialzeKey]: {
239                downloaded: true,
240                local: {
241                    localUrl: pathConst.downloadMusicPath + nextItem.filename,
242                },
243            },
244        });
245        if (downloadingMusicQueue.length === 0) {
246            stopNotifyProgress();
247            Toast.success('下载完成');
248            downloadingMusicQueue = [];
249            pendingMusicQueue = [];
250            downloadingQueueStateMapper.notify();
251            pendingMusicQueueStateMapper.notify();
252        }
253        delete downloadingProgress[nextItem.filename];
254        downloadedStateMapper.notify();
255        downloadNext();
256    } catch {
257        downloadingMusicQueue = produce(downloadingMusicQueue, _ =>
258            _.filter(item => !isSameMediaItem(item.musicItem, musicItem)),
259        );
260    }
261}
262
263/** 下载音乐 */
264function downloadMusic(musicItems: IMusic.IMusicItem | IMusic.IMusicItem[]) {
265    if (Network.isOffline()) {
266        Toast.warn('当前无网络,无法下载');
267        return;
268    }
269    if (
270        Network.isCellular() &&
271        !Config.get('setting.basic.useCelluarNetworkDownload')
272    ) {
273        Toast.warn('当前设置移动网络不可下载,可在侧边栏基本设置修改');
274        return;
275    }
276    // 如果已经在下载中
277    if (!Array.isArray(musicItems)) {
278        musicItems = [musicItems];
279    }
280    musicItems = musicItems.filter(
281        musicItem =>
282            pendingMusicQueue.findIndex(_ =>
283                isSameMediaItem(_.musicItem, musicItem),
284            ) === -1 &&
285            downloadingMusicQueue.findIndex(_ =>
286                isSameMediaItem(_.musicItem, musicItem),
287            ) === -1,
288    );
289    const enqueueData = musicItems.map(_ => ({
290        musicItem: _,
291        filename: generateFilename(_),
292    }));
293    if (enqueueData.length) {
294        pendingMusicQueue = pendingMusicQueue.concat(enqueueData);
295        pendingMusicQueueStateMapper.notify();
296        maxDownload = +(Config.get('setting.basic.maxDownload') ?? 3);
297        downloadNext();
298    }
299}
300
301/** 是否下载 */
302function isDownloaded(mi: IMusic.IMusicItem | null) {
303    return mi
304        ? downloadedMusic.findIndex(_ => isSameMediaItem(_, mi)) !== -1
305        : false;
306}
307
308/** 获取下载的音乐 */
309function getDownloaded(mi: ICommon.IMediaBase | null) {
310    return mi ? downloadedMusic.find(_ => isSameMediaItem(_, mi)) : null;
311}
312
313/** 移除下载的文件 */
314async function removeDownloaded(mi: IMusic.IMusicItem) {
315    const localPath = getDownloaded(mi)?.[internalSymbolKey]?.localPath;
316    if (localPath) {
317        await unlink(localPath);
318        downloadedMusic = downloadedMusic.filter(_ => !isSameMediaItem(_, mi));
319        MediaMeta.update(mi, undefined);
320        downloadedStateMapper.notify();
321    }
322}
323
324/** 某个音乐是否被下载-状态 */
325function useIsDownloaded(mi: IMusic.IMusicItem | null) {
326    const downloadedMusicState = downloadedStateMapper.useMappedState();
327    const [downloaded, setDownloaded] = useState<boolean>(isDownloaded(mi));
328    useEffect(() => {
329        if (!mi) {
330            setDownloaded(false);
331        } else {
332            setDownloaded(
333                downloadedMusicState.findIndex(_ => isSameMediaItem(mi, _)) !==
334                    -1,
335            );
336        }
337    }, [downloadedMusicState, mi]);
338    return downloaded;
339}
340
341const Download = {
342    downloadMusic,
343    setup: setupDownload,
344    useDownloadedMusic: downloadedStateMapper.useMappedState,
345    useDownloadingMusic: downloadingQueueStateMapper.useMappedState,
346    usePendingMusic: pendingMusicQueueStateMapper.useMappedState,
347    useDownloadingProgress: downloadingProgressStateMapper.useMappedState,
348    isDownloaded,
349    useIsDownloaded,
350    getDownloaded,
351    removeDownloaded,
352};
353
354export default Download;
355