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