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