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