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