xref: /MusicFree/src/core/download.ts (revision 9677305be11b30a8953a6c14fd24375953a2309d)
1import {
2    internalSerializeKey,
3    supportLocalMediaType,
4} from '@/constants/commonConst';
5import pathConst from '@/constants/pathConst';
6import {addFileScheme, escapeCharacter, mkdirR} from '@/utils/fileUtils';
7import {errorLog} from '@/utils/log';
8import {isSameMediaItem} from '@/utils/mediaItem';
9import {getQualityOrder} from '@/utils/qualities';
10import StateMapper from '@/utils/stateMapper';
11import Toast from '@/utils/toast';
12import produce from 'immer';
13import {InteractionManager} from 'react-native';
14import {downloadFile, exists} from 'react-native-fs';
15
16import Config from './config';
17import LocalMusicSheet from './localMusicSheet';
18import MediaMeta from './mediaExtra';
19import Network from './network';
20import PluginManager from './pluginManager';
21import {PERMISSIONS, check} from 'react-native-permissions';
22import path from 'path-browserify';
23import {
24    getCurrentDialog,
25    hideDialog,
26    showDialog,
27} from '@/components/dialogs/useDialog';
28// import PQueue from 'p-queue/dist';
29// import PriorityQueue from 'p-queue/dist/priority-queue';
30
31// interface IDownloadProgress {
32//     progress: number;
33//     size: number;
34// }
35
36// const downloadQueue = new PQueue({
37//     concurrency: 3
38// });
39
40// const downloadProgress = new Map<string, IDownloadProgress>();
41// downloadQueue.concurrency = 3;
42// console.log(downloadQueue.concurrency);
43
44/** 队列中的元素 */
45interface IDownloadMusicOptions {
46    /** 要下载的音乐 */
47    musicItem: IMusic.IMusicItem;
48    /** 目标文件名 */
49    filename: string;
50    /** 下载id */
51    jobId?: number;
52    /** 下载音质 */
53    quality?: IMusic.IQualityKey;
54}
55
56/** 下载中 */
57let downloadingMusicQueue: IDownloadMusicOptions[] = [];
58/** 队列中 */
59let pendingMusicQueue: IDownloadMusicOptions[] = [];
60/** 下载进度 */
61let downloadingProgress: Record<string, {progress: number; size: number}> = {};
62/** 错误信息 */
63let hasError: boolean = false;
64
65const downloadingQueueStateMapper = new StateMapper(
66    () => downloadingMusicQueue,
67);
68const pendingMusicQueueStateMapper = new StateMapper(() => pendingMusicQueue);
69const downloadingProgressStateMapper = new StateMapper(
70    () => downloadingProgress,
71);
72
73/** 匹配文件后缀 */
74const getExtensionName = (url: string) => {
75    const regResult = url.match(
76        /^https?\:\/\/.+\.([^\?\.]+?$)|(?:([^\.]+?)\?.+$)/,
77    );
78    if (regResult) {
79        return regResult[1] ?? regResult[2] ?? 'mp3';
80    } else {
81        return 'mp3';
82    }
83};
84
85/** 生成下载文件 */
86const getDownloadPath = (fileName?: string) => {
87    const dlPath =
88        Config.get('setting.basic.downloadPath') ?? pathConst.downloadMusicPath;
89    if (!dlPath.endsWith('/')) {
90        return `${dlPath}/${fileName ?? ''}`;
91    }
92    return fileName ? dlPath + fileName : dlPath;
93};
94
95/** 从待下载中移除 */
96function removeFromPendingQueue(item: IDownloadMusicOptions) {
97    const targetIndex = pendingMusicQueue.findIndex(_ =>
98        isSameMediaItem(_.musicItem, item.musicItem),
99    );
100    if (targetIndex !== -1) {
101        pendingMusicQueue = pendingMusicQueue
102            .slice(0, targetIndex)
103            .concat(pendingMusicQueue.slice(targetIndex + 1));
104        pendingMusicQueueStateMapper.notify();
105    }
106}
107
108/** 从下载中队列移除 */
109function removeFromDownloadingQueue(item: IDownloadMusicOptions) {
110    const targetIndex = downloadingMusicQueue.findIndex(_ =>
111        isSameMediaItem(_.musicItem, item.musicItem),
112    );
113    if (targetIndex !== -1) {
114        downloadingMusicQueue = downloadingMusicQueue
115            .slice(0, targetIndex)
116            .concat(downloadingMusicQueue.slice(targetIndex + 1));
117        downloadingQueueStateMapper.notify();
118    }
119}
120
121/** 防止高频同步 */
122let progressNotifyTimer: any = null;
123function startNotifyProgress() {
124    if (progressNotifyTimer) {
125        return;
126    }
127
128    progressNotifyTimer = setTimeout(() => {
129        progressNotifyTimer = null;
130        downloadingProgressStateMapper.notify();
131        startNotifyProgress();
132    }, 500);
133}
134
135function stopNotifyProgress() {
136    if (progressNotifyTimer) {
137        clearTimeout(progressNotifyTimer);
138    }
139    progressNotifyTimer = null;
140}
141
142/** 生成下载文件名 */
143function generateFilename(musicItem: IMusic.IMusicItem) {
144    return `${escapeCharacter(musicItem.platform)}@${escapeCharacter(
145        musicItem.id,
146    )}@${escapeCharacter(musicItem.title)}@${escapeCharacter(
147        musicItem.artist,
148    )}`.slice(0, 200);
149}
150
151/** todo 可以配置一个说明文件 */
152// async function loadLocalJson(dirBase: string) {
153//   const jsonPath = dirBase + 'data.json';
154//   if (await exists(jsonPath)) {
155//     try {
156//       const result = await readFile(jsonPath, 'utf8');
157//       return JSON.parse(result);
158//     } catch {
159//       return {};
160//     }
161//   }
162//   return {};
163// }
164
165let maxDownload = 3;
166/** 队列下载*/
167async function downloadNext() {
168    // todo 最大同时下载3个,可设置
169    if (
170        downloadingMusicQueue.length >= maxDownload ||
171        pendingMusicQueue.length === 0
172    ) {
173        return;
174    }
175    // 下一个下载的为pending的第一个
176    let nextDownloadItem = pendingMusicQueue[0];
177    const musicItem = nextDownloadItem.musicItem;
178    let url = musicItem.url;
179    let headers = musicItem.headers;
180    removeFromPendingQueue(nextDownloadItem);
181    downloadingMusicQueue = produce(downloadingMusicQueue, draft => {
182        draft.push(nextDownloadItem);
183    });
184    downloadingQueueStateMapper.notify();
185    const quality = nextDownloadItem.quality;
186    const plugin = PluginManager.getByName(musicItem.platform);
187    // 插件播放
188    try {
189        if (plugin) {
190            const qualityOrder = getQualityOrder(
191                quality ??
192                    Config.get('setting.basic.defaultDownloadQuality') ??
193                    'standard',
194                Config.get('setting.basic.downloadQualityOrder') ?? 'asc',
195            );
196            let data: IPlugin.IMediaSourceResult | null = null;
197            for (let quality of qualityOrder) {
198                try {
199                    data = await plugin.methods.getMediaSource(
200                        musicItem,
201                        quality,
202                        1,
203                        true,
204                    );
205                    if (!data?.url) {
206                        continue;
207                    }
208                    break;
209                } catch {}
210            }
211            url = data?.url ?? url;
212            headers = data?.headers;
213        }
214        if (!url) {
215            throw new Error('empty');
216        }
217    } catch (e: any) {
218        /** 无法下载,跳过 */
219        errorLog('下载失败-无法获取下载链接', {
220            item: {
221                id: nextDownloadItem.musicItem.id,
222                title: nextDownloadItem.musicItem.title,
223                platform: nextDownloadItem.musicItem.platform,
224                quality: nextDownloadItem.quality,
225            },
226            reason: e?.message ?? e,
227        });
228        hasError = true;
229        removeFromDownloadingQueue(nextDownloadItem);
230        return;
231    }
232    /** 预处理完成,接下来去下载音乐 */
233    downloadNextAfterInteraction();
234    let extension = getExtensionName(url);
235    const extensionWithDot = `.${extension}`;
236    if (supportLocalMediaType.every(_ => _ !== extensionWithDot)) {
237        extension = 'mp3';
238    }
239    /** 目标下载地址 */
240    const targetDownloadPath = addFileScheme(
241        getDownloadPath(`${nextDownloadItem.filename}.${extension}`),
242    );
243    const {promise, jobId} = downloadFile({
244        fromUrl: url ?? '',
245        toFile: targetDownloadPath,
246        headers: headers,
247        background: true,
248        begin(res) {
249            downloadingProgress = produce(downloadingProgress, _ => {
250                _[nextDownloadItem.filename] = {
251                    progress: 0,
252                    size: res.contentLength,
253                };
254            });
255            startNotifyProgress();
256        },
257        progress(res) {
258            downloadingProgress = produce(downloadingProgress, _ => {
259                _[nextDownloadItem.filename] = {
260                    progress: res.bytesWritten,
261                    size: res.contentLength,
262                };
263            });
264        },
265    });
266    nextDownloadItem = {...nextDownloadItem, jobId};
267
268    let folder = path.dirname(targetDownloadPath);
269    let folderExists = await exists(folder);
270
271    if (!folderExists) {
272        await mkdirR(folder);
273    }
274    try {
275        await promise;
276        /** 下载完成 */
277        LocalMusicSheet.addMusicDraft({
278            ...musicItem,
279            [internalSerializeKey]: {
280                localPath: targetDownloadPath,
281            },
282        });
283        MediaMeta.update(musicItem, {
284            downloaded: true,
285            localPath: targetDownloadPath,
286        });
287        // const primaryKey = plugin?.instance.primaryKey ?? [];
288        // if (!primaryKey.includes('id')) {
289        //     primaryKey.push('id');
290        // }
291        // const stringifyMeta: Record<string, any> = {
292        //     title: musicItem.title,
293        //     artist: musicItem.artist,
294        //     album: musicItem.album,
295        //     lrc: musicItem.lrc,
296        //     platform: musicItem.platform,
297        // };
298        // primaryKey.forEach(_ => {
299        //     stringifyMeta[_] = musicItem[_];
300        // });
301
302        // await Mp3Util.getMediaTag(filePath).then(_ => {
303        //     console.log(_);
304        // }).catch(console.log);
305    } catch (e: any) {
306        console.log(e, 'downloaderror');
307        /** 下载出错 */
308        errorLog('下载失败', {
309            item: {
310                id: nextDownloadItem.musicItem.id,
311                title: nextDownloadItem.musicItem.title,
312                platform: nextDownloadItem.musicItem.platform,
313                quality: nextDownloadItem.quality,
314            },
315            reason: e?.message ?? e,
316            targetDownloadPath: targetDownloadPath,
317        });
318        hasError = true;
319    }
320    removeFromDownloadingQueue(nextDownloadItem);
321    downloadingProgress = produce(downloadingProgress, draft => {
322        if (draft[nextDownloadItem.filename]) {
323            delete draft[nextDownloadItem.filename];
324        }
325    });
326    downloadNextAfterInteraction();
327    if (downloadingMusicQueue.length === 0) {
328        stopNotifyProgress();
329        LocalMusicSheet.saveLocalSheet();
330        if (hasError) {
331            try {
332                const perm = await check(
333                    PERMISSIONS.ANDROID.WRITE_EXTERNAL_STORAGE,
334                );
335                if (perm !== 'granted') {
336                    Toast.warn('权限不足,请检查是否授予写入文件的权限');
337                } else {
338                    throw new Error();
339                }
340            } catch {
341                if (getCurrentDialog()?.name !== 'SimpleDialog') {
342                    showDialog('SimpleDialog', {
343                        title: '下载失败',
344                        content:
345                            '部分歌曲下载失败,如果无法下载请检查系统设置中是否授予完整文件读写权限;或者去【侧边栏-权限管理】中查看文件读写权限是否勾选',
346                        onOk: hideDialog,
347                    });
348                }
349            }
350        } else {
351            Toast.success('下载完成');
352        }
353        hasError = false;
354        downloadingMusicQueue = [];
355        pendingMusicQueue = [];
356        downloadingQueueStateMapper.notify();
357        pendingMusicQueueStateMapper.notify();
358    }
359}
360
361async function downloadNextAfterInteraction() {
362    InteractionManager.runAfterInteractions(downloadNext);
363}
364
365/** 加入下载队列 */
366function downloadMusic(
367    musicItems: IMusic.IMusicItem | IMusic.IMusicItem[],
368    quality?: IMusic.IQualityKey,
369) {
370    if (Network.isOffline()) {
371        Toast.warn('当前无网络,无法下载');
372        return;
373    }
374    if (
375        Network.isCellular() &&
376        !Config.get('setting.basic.useCelluarNetworkDownload')
377    ) {
378        Toast.warn('当前设置移动网络不可下载,可在侧边栏基本设置修改');
379        return;
380    }
381    // 如果已经在下载中
382    if (!Array.isArray(musicItems)) {
383        musicItems = [musicItems];
384    }
385    hasError = false;
386    musicItems = musicItems.filter(
387        musicItem =>
388            pendingMusicQueue.findIndex(_ =>
389                isSameMediaItem(_.musicItem, musicItem),
390            ) === -1 &&
391            downloadingMusicQueue.findIndex(_ =>
392                isSameMediaItem(_.musicItem, musicItem),
393            ) === -1 &&
394            !LocalMusicSheet.isLocalMusic(musicItem),
395    );
396    const enqueueData = musicItems.map(_ => {
397        return {
398            musicItem: _,
399            filename: generateFilename(_),
400            quality,
401        };
402    });
403    if (enqueueData.length) {
404        pendingMusicQueue = pendingMusicQueue.concat(enqueueData);
405        pendingMusicQueueStateMapper.notify();
406        maxDownload = +(Config.get('setting.basic.maxDownload') ?? 3);
407        downloadNextAfterInteraction();
408    }
409}
410
411const Download = {
412    downloadMusic,
413    useDownloadingMusic: downloadingQueueStateMapper.useMappedState,
414    usePendingMusic: pendingMusicQueueStateMapper.useMappedState,
415    useDownloadingProgress: downloadingProgressStateMapper.useMappedState,
416};
417
418export default Download;
419