xref: /MusicFree/src/core/download.ts (revision 095287552b9baf2f2ceeb9397c563c292a4f7934)
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';
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(musicItem, {
279            downloaded: true,
280            localPath: targetDownloadPath,
281        });
282        // const primaryKey = plugin?.instance.primaryKey ?? [];
283        // if (!primaryKey.includes('id')) {
284        //     primaryKey.push('id');
285        // }
286        // const stringifyMeta: Record<string, any> = {
287        //     title: musicItem.title,
288        //     artist: musicItem.artist,
289        //     album: musicItem.album,
290        //     lrc: musicItem.lrc,
291        //     platform: musicItem.platform,
292        // };
293        // primaryKey.forEach(_ => {
294        //     stringifyMeta[_] = musicItem[_];
295        // });
296
297        // await Mp3Util.getMediaTag(filePath).then(_ => {
298        //     console.log(_);
299        // }).catch(console.log);
300    } catch (e: any) {
301        console.log(e, 'downloaderror');
302        /** 下载出错 */
303        errorLog('下载失败', {
304            item: {
305                id: nextDownloadItem.musicItem.id,
306                title: nextDownloadItem.musicItem.title,
307                platform: nextDownloadItem.musicItem.platform,
308                quality: nextDownloadItem.quality,
309            },
310            reason: e?.message ?? e,
311            targetDownloadPath: targetDownloadPath,
312        });
313        hasError = true;
314    }
315    removeFromDownloadingQueue(nextDownloadItem);
316    downloadingProgress = produce(downloadingProgress, draft => {
317        if (draft[nextDownloadItem.filename]) {
318            delete draft[nextDownloadItem.filename];
319        }
320    });
321    downloadNextAfterInteraction();
322    if (downloadingMusicQueue.length === 0) {
323        stopNotifyProgress();
324        LocalMusicSheet.saveLocalSheet();
325        if (hasError) {
326            try {
327                const perm = await check(
328                    PERMISSIONS.ANDROID.WRITE_EXTERNAL_STORAGE,
329                );
330                if (perm !== 'granted') {
331                    Toast.warn('权限不足,请检查是否授予写入文件的权限');
332                } else {
333                    throw new Error();
334                }
335            } catch {
336                Toast.success(
337                    '部分歌曲下载失败,如果无法下载请检查系统设置中是否授予完整存储权限',
338                );
339            }
340        } else {
341            Toast.success('下载完成');
342        }
343        hasError = false;
344        downloadingMusicQueue = [];
345        pendingMusicQueue = [];
346        downloadingQueueStateMapper.notify();
347        pendingMusicQueueStateMapper.notify();
348    }
349}
350
351async function downloadNextAfterInteraction() {
352    InteractionManager.runAfterInteractions(downloadNext);
353}
354
355/** 加入下载队列 */
356function downloadMusic(
357    musicItems: IMusic.IMusicItem | IMusic.IMusicItem[],
358    quality?: IMusic.IQualityKey,
359) {
360    if (Network.isOffline()) {
361        Toast.warn('当前无网络,无法下载');
362        return;
363    }
364    if (
365        Network.isCellular() &&
366        !Config.get('setting.basic.useCelluarNetworkDownload')
367    ) {
368        Toast.warn('当前设置移动网络不可下载,可在侧边栏基本设置修改');
369        return;
370    }
371    // 如果已经在下载中
372    if (!Array.isArray(musicItems)) {
373        musicItems = [musicItems];
374    }
375    hasError = false;
376    musicItems = musicItems.filter(
377        musicItem =>
378            pendingMusicQueue.findIndex(_ =>
379                isSameMediaItem(_.musicItem, musicItem),
380            ) === -1 &&
381            downloadingMusicQueue.findIndex(_ =>
382                isSameMediaItem(_.musicItem, musicItem),
383            ) === -1 &&
384            !LocalMusicSheet.isLocalMusic(musicItem),
385    );
386    const enqueueData = musicItems.map(_ => {
387        return {
388            musicItem: _,
389            filename: generateFilename(_),
390            quality,
391        };
392    });
393    if (enqueueData.length) {
394        pendingMusicQueue = pendingMusicQueue.concat(enqueueData);
395        pendingMusicQueueStateMapper.notify();
396        maxDownload = +(Config.get('setting.basic.maxDownload') ?? 3);
397        downloadNextAfterInteraction();
398    }
399}
400
401const Download = {
402    downloadMusic,
403    useDownloadingMusic: downloadingQueueStateMapper.useMappedState,
404    usePendingMusic: pendingMusicQueueStateMapper.useMappedState,
405    useDownloadingProgress: downloadingProgressStateMapper.useMappedState,
406};
407
408export default Download;
409