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 {copyFile, downloadFile, exists, unlink} 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 {check, PERMISSIONS} from 'react-native-permissions'; 22import path from 'path-browserify'; 23import { 24 getCurrentDialog, 25 hideDialog, 26 showDialog, 27} from '@/components/dialogs/useDialog'; 28import {nanoid} from 'nanoid'; 29 30/** 队列中的元素 */ 31interface IDownloadMusicOptions { 32 /** 要下载的音乐 */ 33 musicItem: IMusic.IMusicItem; 34 /** 目标文件名 */ 35 filename: string; 36 /** 下载id */ 37 jobId?: number; 38 /** 下载音质 */ 39 quality?: IMusic.IQualityKey; 40} 41 42/** 下载中 */ 43let downloadingMusicQueue: IDownloadMusicOptions[] = []; 44/** 队列中 */ 45let pendingMusicQueue: IDownloadMusicOptions[] = []; 46/** 下载进度 */ 47let downloadingProgress: Record<string, {progress: number; size: number}> = {}; 48/** 错误信息 */ 49let hasError: boolean = false; 50 51const downloadingQueueStateMapper = new StateMapper( 52 () => downloadingMusicQueue, 53); 54const pendingMusicQueueStateMapper = new StateMapper(() => pendingMusicQueue); 55const downloadingProgressStateMapper = new StateMapper( 56 () => downloadingProgress, 57); 58 59/** 匹配文件后缀 */ 60const getExtensionName = (url: string) => { 61 const regResult = url.match( 62 /^https?\:\/\/.+\.([^\?\.]+?$)|(?:([^\.]+?)\?.+$)/, 63 ); 64 if (regResult) { 65 return regResult[1] ?? regResult[2] ?? 'mp3'; 66 } else { 67 return 'mp3'; 68 } 69}; 70 71/** 生成下载文件 */ 72const getDownloadPath = (fileName: string) => { 73 const dlPath = 74 Config.get('setting.basic.downloadPath') ?? pathConst.downloadMusicPath; 75 if (!dlPath.endsWith('/')) { 76 return `${dlPath}/${fileName ?? ''}`; 77 } 78 return fileName ? dlPath + fileName : dlPath; 79}; 80 81const getCacheDownloadPath = (fileName: string) => { 82 const cachePath = pathConst.downloadCachePath; 83 if (!cachePath.endsWith('/')) { 84 return `${cachePath}/${fileName ?? ''}`; 85 } 86 return fileName ? cachePath + fileName : cachePath; 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 cacheDownloadPath = addFileScheme( 235 getCacheDownloadPath(`${nanoid()}.${extension}`), 236 ); 237 const targetDownloadPath = addFileScheme( 238 getDownloadPath(`${nextDownloadItem.filename}.${extension}`), 239 ); 240 const {promise, jobId} = downloadFile({ 241 fromUrl: url ?? '', 242 toFile: cacheDownloadPath, 243 headers: headers, 244 background: true, 245 begin(res) { 246 downloadingProgress = produce(downloadingProgress, _ => { 247 _[nextDownloadItem.filename] = { 248 progress: 0, 249 size: res.contentLength, 250 }; 251 }); 252 startNotifyProgress(); 253 }, 254 progress(res) { 255 downloadingProgress = produce(downloadingProgress, _ => { 256 _[nextDownloadItem.filename] = { 257 progress: res.bytesWritten, 258 size: res.contentLength, 259 }; 260 }); 261 }, 262 }); 263 nextDownloadItem = {...nextDownloadItem, jobId}; 264 265 let folder = path.dirname(targetDownloadPath); 266 let folderExists = await exists(folder); 267 268 if (!folderExists) { 269 await mkdirR(folder); 270 } 271 272 try { 273 await promise; 274 await copyFile(cacheDownloadPath, targetDownloadPath); 275 /** 下载完成 */ 276 LocalMusicSheet.addMusicDraft({ 277 ...musicItem, 278 [internalSerializeKey]: { 279 localPath: targetDownloadPath, 280 }, 281 }); 282 MediaMeta.update(musicItem, { 283 downloaded: true, 284 localPath: targetDownloadPath, 285 }); 286 // const primaryKey = plugin?.instance.primaryKey ?? []; 287 // if (!primaryKey.includes('id')) { 288 // primaryKey.push('id'); 289 // } 290 // const stringifyMeta: Record<string, any> = { 291 // title: musicItem.title, 292 // artist: musicItem.artist, 293 // album: musicItem.album, 294 // lrc: musicItem.lrc, 295 // platform: musicItem.platform, 296 // }; 297 // primaryKey.forEach(_ => { 298 // stringifyMeta[_] = musicItem[_]; 299 // }); 300 301 // await Mp3Util.getMediaTag(filePath).then(_ => { 302 // console.log(_); 303 // }).catch(console.log); 304 } catch (e: any) { 305 console.log(e, 'downloaderror'); 306 /** 下载出错 */ 307 errorLog('下载失败', { 308 item: { 309 id: nextDownloadItem.musicItem.id, 310 title: nextDownloadItem.musicItem.title, 311 platform: nextDownloadItem.musicItem.platform, 312 quality: nextDownloadItem.quality, 313 }, 314 reason: e?.message ?? e, 315 targetDownloadPath: targetDownloadPath, 316 }); 317 hasError = true; 318 } finally { 319 await unlink(cacheDownloadPath); 320 } 321 removeFromDownloadingQueue(nextDownloadItem); 322 downloadingProgress = produce(downloadingProgress, draft => { 323 if (draft[nextDownloadItem.filename]) { 324 delete draft[nextDownloadItem.filename]; 325 } 326 }); 327 downloadNextAfterInteraction(); 328 if (downloadingMusicQueue.length === 0) { 329 stopNotifyProgress(); 330 LocalMusicSheet.saveLocalSheet(); 331 if (hasError) { 332 try { 333 const perm = await check( 334 PERMISSIONS.ANDROID.WRITE_EXTERNAL_STORAGE, 335 ); 336 if (perm !== 'granted') { 337 Toast.warn('权限不足,请检查是否授予写入文件的权限'); 338 } else { 339 throw new Error(); 340 } 341 } catch { 342 if (getCurrentDialog()?.name !== 'SimpleDialog') { 343 showDialog('SimpleDialog', { 344 title: '下载失败', 345 content: 346 '部分歌曲下载失败,如果无法下载请检查系统设置中是否授予完整文件读写权限;或者去【侧边栏-权限管理】中查看文件读写权限是否勾选', 347 onOk: hideDialog, 348 }); 349 } 350 } 351 } else { 352 Toast.success('下载完成'); 353 } 354 hasError = false; 355 downloadingMusicQueue = []; 356 pendingMusicQueue = []; 357 downloadingQueueStateMapper.notify(); 358 pendingMusicQueueStateMapper.notify(); 359 } 360} 361 362async function downloadNextAfterInteraction() { 363 InteractionManager.runAfterInteractions(downloadNext); 364} 365 366/** 加入下载队列 */ 367function downloadMusic( 368 musicItems: IMusic.IMusicItem | IMusic.IMusicItem[], 369 quality?: IMusic.IQualityKey, 370) { 371 if (Network.isOffline()) { 372 Toast.warn('当前无网络,无法下载'); 373 return; 374 } 375 if ( 376 Network.isCellular() && 377 !Config.get('setting.basic.useCelluarNetworkDownload') && 378 getCurrentDialog()?.name !== 'SimpleDialog' 379 ) { 380 showDialog('SimpleDialog', { 381 title: '流量提醒', 382 content: 383 '当前非WIFI环境,侧边栏设置中打开【使用移动网络下载】功能后可继续下载', 384 }); 385 return; 386 } 387 // 如果已经在下载中 388 if (!Array.isArray(musicItems)) { 389 musicItems = [musicItems]; 390 } 391 hasError = false; 392 musicItems = musicItems.filter( 393 musicItem => 394 pendingMusicQueue.findIndex(_ => 395 isSameMediaItem(_.musicItem, musicItem), 396 ) === -1 && 397 downloadingMusicQueue.findIndex(_ => 398 isSameMediaItem(_.musicItem, musicItem), 399 ) === -1 && 400 !LocalMusicSheet.isLocalMusic(musicItem), 401 ); 402 const enqueueData = musicItems.map(_ => { 403 return { 404 musicItem: _, 405 filename: generateFilename(_), 406 quality, 407 }; 408 }); 409 if (enqueueData.length) { 410 pendingMusicQueue = pendingMusicQueue.concat(enqueueData); 411 pendingMusicQueueStateMapper.notify(); 412 maxDownload = +(Config.get('setting.basic.maxDownload') ?? 3); 413 downloadNextAfterInteraction(); 414 } 415} 416 417const Download = { 418 downloadMusic, 419 useDownloadingMusic: downloadingQueueStateMapper.useMappedState, 420 usePendingMusic: pendingMusicQueueStateMapper.useMappedState, 421 useDownloadingProgress: downloadingProgressStateMapper.useMappedState, 422}; 423 424export default Download; 425