1import {internalSerialzeKey, internalSymbolKey} from '@/constants/commonConst'; 2import pathConst from '@/constants/pathConst'; 3import {checkAndCreateDir} from '@/utils/fileUtils'; 4import {errorLog} from '@/utils/log'; 5import {isSameMediaItem} from '@/utils/mediaItem'; 6import StateMapper from '@/utils/stateMapper'; 7import {setStorage} from '@/utils/storage'; 8import Toast from '@/utils/toast'; 9import produce from 'immer'; 10import {useEffect, useState} from 'react'; 11import {unlink, downloadFile, readDir} from 'react-native-fs'; 12 13import Config from './config'; 14import MediaMeta from './mediaMeta'; 15import Network from './network'; 16import PluginManager from './pluginManager'; 17 18interface IDownloadMusicOptions { 19 musicItem: IMusic.IMusicItem; 20 filename: string; 21 jobId?: number; 22} 23// todo: 直接把下载信息写在meta里面就好了 24/** 已下载 */ 25let downloadedMusic: IMusic.IMusicItem[] = []; 26/** 下载中 */ 27let downloadingMusicQueue: IDownloadMusicOptions[] = []; 28/** 队列中 */ 29let pendingMusicQueue: IDownloadMusicOptions[] = []; 30 31/** 进度 */ 32let downloadingProgress: Record<string, {progress: number; size: number}> = {}; 33 34const downloadedStateMapper = new StateMapper(() => downloadedMusic); 35const downloadingQueueStateMapper = new StateMapper( 36 () => downloadingMusicQueue, 37); 38const pendingMusicQueueStateMapper = new StateMapper(() => pendingMusicQueue); 39const downloadingProgressStateMapper = new StateMapper( 40 () => downloadingProgress, 41); 42 43/** 从待下载中移除 */ 44function removeFromPendingQueue(item: IDownloadMusicOptions) { 45 pendingMusicQueue = pendingMusicQueue.filter( 46 _ => !isSameMediaItem(_.musicItem, item.musicItem), 47 ); 48 pendingMusicQueueStateMapper.notify(); 49} 50 51/** 从下载中队列移除 */ 52function removeFromDownloadingQueue(item: IDownloadMusicOptions) { 53 downloadingMusicQueue = downloadingMusicQueue.filter( 54 _ => !isSameMediaItem(_.musicItem, item.musicItem), 55 ); 56 downloadingQueueStateMapper.notify(); 57} 58 59/** 防止高频同步 */ 60let progressNotifyTimer: any = null; 61function startNotifyProgress() { 62 if (progressNotifyTimer) { 63 return; 64 } 65 66 progressNotifyTimer = setTimeout(() => { 67 progressNotifyTimer = null; 68 downloadingProgressStateMapper.notify(); 69 startNotifyProgress(); 70 }, 400); 71} 72 73function stopNotifyProgress() { 74 if (progressNotifyTimer) { 75 clearInterval(progressNotifyTimer); 76 } 77 progressNotifyTimer = null; 78} 79 80/** 根据文件名解析 */ 81function parseFilename(fn: string): IMusic.IMusicItemBase | null { 82 const data = fn.slice(0, fn.lastIndexOf('.')).split('@'); 83 const [platform, id, title, artist] = data; 84 if (!platform || !id) { 85 return null; 86 } 87 return { 88 id, 89 platform, 90 title, 91 artist, 92 }; 93} 94 95/** 生成下载文件名 */ 96function generateFilename(musicItem: IMusic.IMusicItem) { 97 return ( 98 `${musicItem.platform}@${musicItem.id}@${musicItem.title}@${musicItem.artist}`.slice( 99 0, 100 200, 101 ) + '.mp3' 102 ); 103} 104 105/** todo 可以配置一个说明文件 */ 106// async function loadLocalJson(dirBase: string) { 107// const jsonPath = dirBase + 'data.json'; 108// if (await exists(jsonPath)) { 109// try { 110// const result = await readFile(jsonPath, 'utf8'); 111// return JSON.parse(result); 112// } catch { 113// return {}; 114// } 115// } 116// return {}; 117// } 118 119/** 初始化 */ 120async function setupDownload() { 121 await checkAndCreateDir(pathConst.downloadMusicPath); 122 // const jsonData = await loadLocalJson(pathConst.downloadMusicPath); 123 124 const newDownloadedData: Record<string, IMusic.IMusicItem> = {}; 125 downloadedMusic = []; 126 127 try { 128 const downloads = await readDir(pathConst.downloadMusicPath); 129 for (let i = 0; i < downloads.length; ++i) { 130 const data = parseFilename(downloads[i].name); 131 if (data) { 132 const platform = data?.platform; 133 const id = data?.id; 134 if (platform && id) { 135 const mi = MediaMeta.get(data) ?? {}; 136 mi.id = id; 137 mi.platform = platform; 138 mi.title = mi.title ?? data.title; 139 mi.artist = mi.artist ?? data.artist; 140 mi[internalSymbolKey] = { 141 localPath: downloads[i].path, 142 }; 143 downloadedMusic.push(mi as IMusic.IMusicItem); 144 } 145 } 146 } 147 148 downloadedStateMapper.notify(); 149 // 去掉冗余数据 150 setStorage('download-music', newDownloadedData); 151 } catch (e) { 152 errorLog('本地下载初始化失败', e); 153 } 154} 155 156let maxDownload = 3; 157/** 从队列取出下一个要下载的 */ 158async function downloadNext() { 159 // todo 最大同时下载3个,可设置 160 if ( 161 downloadingMusicQueue.length >= maxDownload || 162 pendingMusicQueue.length === 0 163 ) { 164 return; 165 } 166 const nextItem = pendingMusicQueue[0]; 167 const musicItem = nextItem.musicItem; 168 let url = musicItem.url; 169 let headers = musicItem.headers; 170 removeFromPendingQueue(nextItem); 171 downloadingMusicQueue = produce(downloadingMusicQueue, draft => { 172 draft.push(nextItem); 173 }); 174 downloadingQueueStateMapper.notify(); 175 if (!url || !url?.startsWith('http')) { 176 // 插件播放 177 const plugin = PluginManager.getByName(musicItem.platform); 178 if (plugin) { 179 try { 180 const data = await plugin.methods.getMediaSource(musicItem); 181 url = data?.url; 182 headers = data?.headers; 183 } catch { 184 /** 无法下载,跳过 */ 185 removeFromDownloadingQueue(nextItem); 186 return; 187 } 188 } 189 } 190 191 downloadNext(); 192 const {promise, jobId} = downloadFile({ 193 fromUrl: url ?? '', 194 toFile: pathConst.downloadMusicPath + nextItem.filename, 195 headers: headers, 196 background: true, 197 begin(res) { 198 downloadingProgress = produce(downloadingProgress, _ => { 199 _[nextItem.filename] = { 200 progress: 0, 201 size: res.contentLength, 202 }; 203 }); 204 startNotifyProgress(); 205 }, 206 progress(res) { 207 downloadingProgress = produce(downloadingProgress, _ => { 208 _[nextItem.filename] = { 209 progress: res.bytesWritten, 210 size: res.contentLength, 211 }; 212 }); 213 }, 214 }); 215 nextItem.jobId = jobId; 216 try { 217 await promise; 218 // 下载完成 219 downloadedMusic = produce(downloadedMusic, _ => { 220 if ( 221 downloadedMusic.findIndex(_ => 222 isSameMediaItem(musicItem, _), 223 ) === -1 224 ) { 225 _.push({ 226 ...musicItem, 227 [internalSymbolKey]: { 228 localPath: 229 pathConst.downloadMusicPath + nextItem.filename, 230 }, 231 }); 232 } 233 return _; 234 }); 235 removeFromDownloadingQueue(nextItem); 236 MediaMeta.update({ 237 ...musicItem, 238 [internalSerialzeKey]: { 239 downloaded: true, 240 local: { 241 localUrl: pathConst.downloadMusicPath + nextItem.filename, 242 }, 243 }, 244 }); 245 if (downloadingMusicQueue.length === 0) { 246 stopNotifyProgress(); 247 Toast.success('下载完成'); 248 downloadingMusicQueue = []; 249 pendingMusicQueue = []; 250 downloadingQueueStateMapper.notify(); 251 pendingMusicQueueStateMapper.notify(); 252 } 253 delete downloadingProgress[nextItem.filename]; 254 downloadedStateMapper.notify(); 255 downloadNext(); 256 } catch { 257 downloadingMusicQueue = produce(downloadingMusicQueue, _ => 258 _.filter(item => !isSameMediaItem(item.musicItem, musicItem)), 259 ); 260 } 261} 262 263/** 下载音乐 */ 264function downloadMusic(musicItems: IMusic.IMusicItem | IMusic.IMusicItem[]) { 265 if (Network.isOffline()) { 266 Toast.warn('当前无网络,无法下载'); 267 return; 268 } 269 if ( 270 Network.isCellular() && 271 !Config.get('setting.basic.useCelluarNetworkDownload') 272 ) { 273 Toast.warn('当前设置移动网络不可下载,可在侧边栏基本设置修改'); 274 return; 275 } 276 // 如果已经在下载中 277 if (!Array.isArray(musicItems)) { 278 musicItems = [musicItems]; 279 } 280 musicItems = musicItems.filter( 281 musicItem => 282 pendingMusicQueue.findIndex(_ => 283 isSameMediaItem(_.musicItem, musicItem), 284 ) === -1 && 285 downloadingMusicQueue.findIndex(_ => 286 isSameMediaItem(_.musicItem, musicItem), 287 ) === -1, 288 ); 289 const enqueueData = musicItems.map(_ => ({ 290 musicItem: _, 291 filename: generateFilename(_), 292 })); 293 if (enqueueData.length) { 294 pendingMusicQueue = pendingMusicQueue.concat(enqueueData); 295 pendingMusicQueueStateMapper.notify(); 296 maxDownload = +(Config.get('setting.basic.maxDownload') ?? 3); 297 downloadNext(); 298 } 299} 300 301/** 是否下载 */ 302function isDownloaded(mi: IMusic.IMusicItem | null) { 303 return mi 304 ? downloadedMusic.findIndex(_ => isSameMediaItem(_, mi)) !== -1 305 : false; 306} 307 308/** 获取下载的音乐 */ 309function getDownloaded(mi: ICommon.IMediaBase | null) { 310 return mi ? downloadedMusic.find(_ => isSameMediaItem(_, mi)) : null; 311} 312 313/** 移除下载的文件 */ 314async function removeDownloaded(mi: IMusic.IMusicItem) { 315 const localPath = getDownloaded(mi)?.[internalSymbolKey]?.localPath; 316 if (localPath) { 317 await unlink(localPath); 318 downloadedMusic = downloadedMusic.filter(_ => !isSameMediaItem(_, mi)); 319 MediaMeta.update(mi, undefined); 320 downloadedStateMapper.notify(); 321 } 322} 323 324/** 某个音乐是否被下载-状态 */ 325function useIsDownloaded(mi: IMusic.IMusicItem | null) { 326 const downloadedMusicState = downloadedStateMapper.useMappedState(); 327 const [downloaded, setDownloaded] = useState<boolean>(isDownloaded(mi)); 328 useEffect(() => { 329 if (!mi) { 330 setDownloaded(false); 331 } else { 332 setDownloaded( 333 downloadedMusicState.findIndex(_ => isSameMediaItem(mi, _)) !== 334 -1, 335 ); 336 } 337 }, [downloadedMusicState, mi]); 338 return downloaded; 339} 340 341const Download = { 342 downloadMusic, 343 setup: setupDownload, 344 useDownloadedMusic: downloadedStateMapper.useMappedState, 345 useDownloadingMusic: downloadingQueueStateMapper.useMappedState, 346 usePendingMusic: pendingMusicQueueStateMapper.useMappedState, 347 useDownloadingProgress: downloadingProgressStateMapper.useMappedState, 348 isDownloaded, 349 useIsDownloaded, 350 getDownloaded, 351 removeDownloaded, 352}; 353 354export default Download; 355