xref: /MusicFree/src/core/download.ts (revision 1fa77b042dffea2ad8db31c1b15672ed8f3755cf)
1import {
2    internalSerializeKey,
3    supportLocalMediaType,
4} from '@/constants/commonConst';
5import pathConst from '@/constants/pathConst';
6import {addFileScheme, escapeCharacter} 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} from 'react-native-fs';
15
16import Config from './config';
17import LocalMusicSheet from './localMusicSheet';
18import MediaMeta from './mediaMeta';
19import Network from './network';
20import PluginManager from './pluginManager';
21import {PERMISSIONS, check} from 'react-native-permissions';
22// import PQueue from 'p-queue/dist';
23// import PriorityQueue from 'p-queue/dist/priority-queue';
24
25// interface IDownloadProgress {
26//     progress: number;
27//     size: number;
28// }
29
30// const downloadQueue = new PQueue({
31//     concurrency: 3
32// });
33
34// const downloadProgress = new Map<string, IDownloadProgress>();
35// downloadQueue.concurrency = 3;
36// console.log(downloadQueue.concurrency);
37
38/** 队列中的元素 */
39interface IDownloadMusicOptions {
40    /** 要下载的音乐 */
41    musicItem: IMusic.IMusicItem;
42    /** 目标文件名 */
43    filename: string;
44    /** 下载id */
45    jobId?: number;
46    /** 下载音质 */
47    quality?: IMusic.IQualityKey;
48}
49
50/** 下载中 */
51let downloadingMusicQueue: IDownloadMusicOptions[] = [];
52/** 队列中 */
53let pendingMusicQueue: IDownloadMusicOptions[] = [];
54/** 下载进度 */
55let downloadingProgress: Record<string, {progress: number; size: number}> = {};
56/** 错误信息 */
57let hasError: boolean = false;
58
59const downloadingQueueStateMapper = new StateMapper(
60    () => downloadingMusicQueue,
61);
62const pendingMusicQueueStateMapper = new StateMapper(() => pendingMusicQueue);
63const downloadingProgressStateMapper = new StateMapper(
64    () => downloadingProgress,
65);
66
67/** 匹配文件后缀 */
68const getExtensionName = (url: string) => {
69    const regResult = url.match(
70        /^https?\:\/\/.+\.([^\?\.]+?$)|(?:([^\.]+?)\?.+$)/,
71    );
72    if (regResult) {
73        return regResult[1] ?? regResult[2] ?? 'mp3';
74    } else {
75        return 'mp3';
76    }
77};
78
79/** 生成下载文件 */
80const getDownloadPath = (fileName?: string) => {
81    const dlPath =
82        Config.get('setting.basic.downloadPath') ?? pathConst.downloadMusicPath;
83    if (!dlPath.endsWith('/')) {
84        return `${dlPath}/${fileName ?? ''}`;
85    }
86    return fileName ? dlPath + fileName : dlPath;
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 targetDownloadPath = addFileScheme(
235        getDownloadPath(`${nextDownloadItem.filename}.${extension}`),
236    );
237    const {promise, jobId} = downloadFile({
238        fromUrl: url ?? '',
239        toFile: targetDownloadPath,
240        headers: headers,
241        background: true,
242        begin(res) {
243            downloadingProgress = produce(downloadingProgress, _ => {
244                _[nextDownloadItem.filename] = {
245                    progress: 0,
246                    size: res.contentLength,
247                };
248            });
249            startNotifyProgress();
250        },
251        progress(res) {
252            downloadingProgress = produce(downloadingProgress, _ => {
253                _[nextDownloadItem.filename] = {
254                    progress: res.bytesWritten,
255                    size: res.contentLength,
256                };
257            });
258        },
259    });
260    nextDownloadItem = {...nextDownloadItem, jobId};
261    try {
262        await promise;
263        /** 下载完成 */
264        LocalMusicSheet.addMusicDraft({
265            ...musicItem,
266            [internalSerializeKey]: {
267                localPath: targetDownloadPath,
268            },
269        });
270        MediaMeta.update({
271            ...musicItem,
272            [internalSerializeKey]: {
273                downloaded: true,
274                local: {
275                    localUrl: targetDownloadPath,
276                },
277            },
278        });
279        // const primaryKey = plugin?.instance.primaryKey ?? [];
280        // if (!primaryKey.includes('id')) {
281        //     primaryKey.push('id');
282        // }
283        // const stringifyMeta: Record<string, any> = {
284        //     title: musicItem.title,
285        //     artist: musicItem.artist,
286        //     album: musicItem.album,
287        //     lrc: musicItem.lrc,
288        //     platform: musicItem.platform,
289        // };
290        // primaryKey.forEach(_ => {
291        //     stringifyMeta[_] = musicItem[_];
292        // });
293
294        // await Mp3Util.getMediaTag(filePath).then(_ => {
295        //     console.log(_);
296        // }).catch(console.log);
297    } catch (e: any) {
298        console.log(e, 'downloaderror');
299        /** 下载出错 */
300        errorLog('下载失败', {
301            item: {
302                id: nextDownloadItem.musicItem.id,
303                title: nextDownloadItem.musicItem.title,
304                platform: nextDownloadItem.musicItem.platform,
305                quality: nextDownloadItem.quality,
306            },
307            reason: e?.message ?? e,
308        });
309        hasError = true;
310    }
311    removeFromDownloadingQueue(nextDownloadItem);
312    downloadingProgress = produce(downloadingProgress, draft => {
313        if (draft[nextDownloadItem.filename]) {
314            delete draft[nextDownloadItem.filename];
315        }
316    });
317    downloadNextAfterInteraction();
318    if (downloadingMusicQueue.length === 0) {
319        stopNotifyProgress();
320        LocalMusicSheet.saveLocalSheet();
321        if (hasError) {
322            try {
323                const perm = await check(
324                    PERMISSIONS.ANDROID.WRITE_EXTERNAL_STORAGE,
325                );
326                if (perm !== 'granted') {
327                    Toast.success('权限不足,请检查是否授予写入文件的权限');
328                } else {
329                    throw new Error();
330                }
331            } catch {
332                Toast.success(
333                    '部分下载失败,如果重复出现此现象请打开“侧边栏-记录错误日志”辅助排查',
334                );
335            }
336        } else {
337            Toast.success('下载完成');
338        }
339        hasError = false;
340        downloadingMusicQueue = [];
341        pendingMusicQueue = [];
342        downloadingQueueStateMapper.notify();
343        pendingMusicQueueStateMapper.notify();
344    }
345}
346
347async function downloadNextAfterInteraction() {
348    InteractionManager.runAfterInteractions(downloadNext);
349}
350
351/** 加入下载队列 */
352function downloadMusic(
353    musicItems: IMusic.IMusicItem | IMusic.IMusicItem[],
354    quality?: IMusic.IQualityKey,
355) {
356    if (Network.isOffline()) {
357        Toast.warn('当前无网络,无法下载');
358        return;
359    }
360    if (
361        Network.isCellular() &&
362        !Config.get('setting.basic.useCelluarNetworkDownload')
363    ) {
364        Toast.warn('当前设置移动网络不可下载,可在侧边栏基本设置修改');
365        return;
366    }
367    // 如果已经在下载中
368    if (!Array.isArray(musicItems)) {
369        musicItems = [musicItems];
370    }
371    hasError = false;
372    musicItems = musicItems.filter(
373        musicItem =>
374            pendingMusicQueue.findIndex(_ =>
375                isSameMediaItem(_.musicItem, musicItem),
376            ) === -1 &&
377            downloadingMusicQueue.findIndex(_ =>
378                isSameMediaItem(_.musicItem, musicItem),
379            ) === -1 &&
380            !LocalMusicSheet.isLocalMusic(musicItem),
381    );
382    const enqueueData = musicItems.map(_ => {
383        return {
384            musicItem: _,
385            filename: generateFilename(_),
386            quality,
387        };
388    });
389    if (enqueueData.length) {
390        pendingMusicQueue = pendingMusicQueue.concat(enqueueData);
391        pendingMusicQueueStateMapper.notify();
392        maxDownload = +(Config.get('setting.basic.maxDownload') ?? 3);
393        downloadNextAfterInteraction();
394    }
395}
396
397const Download = {
398    downloadMusic,
399    useDownloadingMusic: downloadingQueueStateMapper.useMappedState,
400    usePendingMusic: pendingMusicQueueStateMapper.useMappedState,
401    useDownloadingProgress: downloadingProgressStateMapper.useMappedState,
402};
403
404export default Download;
405