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