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