xref: /MusicFree/src/core/download.ts (revision 15a52c01d4ac71a8f54e90091a77cbf2361a75d8)
1import {internalSerializeKey} from '@/constants/commonConst';
2import pathConst from '@/constants/pathConst';
3import {isSameMediaItem} from '@/utils/mediaItem';
4import {getQualityOrder} from '@/utils/qualities';
5import StateMapper from '@/utils/stateMapper';
6import Toast from '@/utils/toast';
7import produce from 'immer';
8import {downloadFile} from 'react-native-fs';
9
10import Config from './config';
11import LocalMusicSheet from './localMusicSheet';
12import MediaMeta from './mediaMeta';
13import Network from './network';
14import PluginManager from './pluginManager';
15
16interface IDownloadMusicOptions {
17    musicItem: IMusic.IMusicItem;
18    filename: string;
19    jobId?: number;
20}
21// todo: 直接把下载信息写在meta里面就好了
22/** 下载中 */
23let downloadingMusicQueue: IDownloadMusicOptions[] = [];
24/** 队列中 */
25let pendingMusicQueue: IDownloadMusicOptions[] = [];
26
27/** 进度 */
28let downloadingProgress: Record<string, {progress: number; size: number}> = {};
29
30const downloadingQueueStateMapper = new StateMapper(
31    () => downloadingMusicQueue,
32);
33const pendingMusicQueueStateMapper = new StateMapper(() => pendingMusicQueue);
34const downloadingProgressStateMapper = new StateMapper(
35    () => downloadingProgress,
36);
37
38const getDownloadPath = (fileName?: string) => {
39    const dlPath =
40        Config.get('setting.basic.downloadPath') ?? pathConst.downloadMusicPath;
41    if (!dlPath.endsWith('/')) {
42        return `${dlPath}/${fileName ?? ''}`;
43    }
44    return fileName ? dlPath + fileName : dlPath;
45};
46
47/** 从待下载中移除 */
48function removeFromPendingQueue(item: IDownloadMusicOptions) {
49    pendingMusicQueue = pendingMusicQueue.filter(
50        _ => !isSameMediaItem(_.musicItem, item.musicItem),
51    );
52    pendingMusicQueueStateMapper.notify();
53}
54
55/** 从下载中队列移除 */
56function removeFromDownloadingQueue(item: IDownloadMusicOptions) {
57    downloadingMusicQueue = downloadingMusicQueue.filter(
58        _ => !isSameMediaItem(_.musicItem, item.musicItem),
59    );
60    downloadingQueueStateMapper.notify();
61}
62
63/** 防止高频同步 */
64let progressNotifyTimer: any = null;
65function startNotifyProgress() {
66    if (progressNotifyTimer) {
67        return;
68    }
69
70    progressNotifyTimer = setTimeout(() => {
71        progressNotifyTimer = null;
72        downloadingProgressStateMapper.notify();
73        startNotifyProgress();
74    }, 400);
75}
76
77function stopNotifyProgress() {
78    if (progressNotifyTimer) {
79        clearInterval(progressNotifyTimer);
80    }
81    progressNotifyTimer = null;
82}
83
84/** 生成下载文件名 */
85function generateFilename(musicItem: IMusic.IMusicItem) {
86    return (
87        `${musicItem.platform}@${musicItem.id}@${musicItem.title}@${musicItem.artist}`.slice(
88            0,
89            200,
90        ) + '.mp3'
91    );
92}
93
94/** todo 可以配置一个说明文件 */
95// async function loadLocalJson(dirBase: string) {
96//   const jsonPath = dirBase + 'data.json';
97//   if (await exists(jsonPath)) {
98//     try {
99//       const result = await readFile(jsonPath, 'utf8');
100//       return JSON.parse(result);
101//     } catch {
102//       return {};
103//     }
104//   }
105//   return {};
106// }
107
108let maxDownload = 3;
109/** 从队列取出下一个要下载的 */
110async function downloadNext() {
111    // todo 最大同时下载3个,可设置
112    if (
113        downloadingMusicQueue.length >= maxDownload ||
114        pendingMusicQueue.length === 0
115    ) {
116        return;
117    }
118    let nextItem = pendingMusicQueue[0];
119    const musicItem = nextItem.musicItem;
120    let url = musicItem.url;
121    let headers = musicItem.headers;
122    removeFromPendingQueue(nextItem);
123    downloadingMusicQueue = produce(downloadingMusicQueue, draft => {
124        draft.push(nextItem);
125    });
126    downloadingQueueStateMapper.notify();
127    if (!url || !url?.startsWith('http')) {
128        // 插件播放
129        const plugin = PluginManager.getByName(musicItem.platform);
130        if (plugin) {
131            try {
132                const qualityOrder = getQualityOrder(
133                    Config.get('setting.basic.defaultDownloadQuality') ??
134                        'standard',
135                    Config.get('setting.basic.downloadQualityOrder') ?? 'asc',
136                );
137                let data: IPlugin.IMediaSourceResult | null = null;
138                for (let quality of qualityOrder) {
139                    try {
140                        data = await plugin.methods.getMediaSource(
141                            musicItem,
142                            quality,
143                        );
144                        if (!data?.url) {
145                            continue;
146                        }
147                        break;
148                    } catch {}
149                }
150                url = data?.url ?? url;
151                headers = data?.headers;
152            } catch {
153                /** 无法下载,跳过 */
154                removeFromDownloadingQueue(nextItem);
155                return;
156            }
157        }
158    }
159
160    downloadNext();
161    const {promise, jobId} = downloadFile({
162        fromUrl: url ?? '',
163        toFile: getDownloadPath(nextItem.filename),
164        headers: headers,
165        background: true,
166        begin(res) {
167            downloadingProgress = produce(downloadingProgress, _ => {
168                _[nextItem.filename] = {
169                    progress: 0,
170                    size: res.contentLength,
171                };
172            });
173            startNotifyProgress();
174        },
175        progress(res) {
176            downloadingProgress = produce(downloadingProgress, _ => {
177                _[nextItem.filename] = {
178                    progress: res.bytesWritten,
179                    size: res.contentLength,
180                };
181            });
182        },
183    });
184    nextItem = {...nextItem, jobId};
185    try {
186        await promise;
187        LocalMusicSheet.addMusic({
188            ...musicItem,
189            [internalSerializeKey]: {
190                localPath: getDownloadPath(nextItem.filename),
191            },
192        });
193        removeFromDownloadingQueue(nextItem);
194        MediaMeta.update({
195            ...musicItem,
196            [internalSerializeKey]: {
197                downloaded: true,
198                local: {
199                    localUrl: getDownloadPath(nextItem.filename),
200                },
201            },
202        });
203        if (downloadingMusicQueue.length === 0) {
204            stopNotifyProgress();
205            Toast.success('下载完成');
206            downloadingMusicQueue = [];
207            pendingMusicQueue = [];
208            downloadingQueueStateMapper.notify();
209            pendingMusicQueueStateMapper.notify();
210        }
211        delete downloadingProgress[nextItem.filename];
212        downloadNext();
213    } catch {
214        downloadingMusicQueue = produce(downloadingMusicQueue, _ =>
215            _.filter(item => !isSameMediaItem(item.musicItem, musicItem)),
216        );
217    }
218}
219
220/** 下载音乐 */
221function downloadMusic(musicItems: IMusic.IMusicItem | IMusic.IMusicItem[]) {
222    if (Network.isOffline()) {
223        Toast.warn('当前无网络,无法下载');
224        return;
225    }
226    if (
227        Network.isCellular() &&
228        !Config.get('setting.basic.useCelluarNetworkDownload')
229    ) {
230        Toast.warn('当前设置移动网络不可下载,可在侧边栏基本设置修改');
231        return;
232    }
233    // 如果已经在下载中
234    if (!Array.isArray(musicItems)) {
235        musicItems = [musicItems];
236    }
237    musicItems = musicItems.filter(
238        musicItem =>
239            pendingMusicQueue.findIndex(_ =>
240                isSameMediaItem(_.musicItem, musicItem),
241            ) === -1 &&
242            downloadingMusicQueue.findIndex(_ =>
243                isSameMediaItem(_.musicItem, musicItem),
244            ) === -1 &&
245            !LocalMusicSheet.isLocalMusic(musicItem),
246    );
247    const enqueueData = musicItems.map(_ => ({
248        musicItem: _,
249        filename: generateFilename(_),
250    }));
251    if (enqueueData.length) {
252        pendingMusicQueue = pendingMusicQueue.concat(enqueueData);
253        pendingMusicQueueStateMapper.notify();
254        maxDownload = +(Config.get('setting.basic.maxDownload') ?? 3);
255        downloadNext();
256    }
257}
258
259const Download = {
260    downloadMusic,
261    useDownloadingMusic: downloadingQueueStateMapper.useMappedState,
262    usePendingMusic: pendingMusicQueueStateMapper.useMappedState,
263    useDownloadingProgress: downloadingProgressStateMapper.useMappedState,
264};
265
266export default Download;
267