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