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