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