// // MP_DownloadManager.swift // MusicPlayer // // Created by 忆海16 on 2024/5/14. // import Foundation import Tiercel import DownloadButton enum MP_DownloadTaskStatus { ///处于队列中 case queued ///下载中 case downloading ///下载完成 case finished ///需要重新下载 case failed } ///下载管理器 class MP_DownloadManager: NSObject { static let shared = MP_DownloadManager() //文件管理器 private let fileManager = FileManager.default //沙盒文件 private let DocumentsURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] ///会话队列 var loadQueue:DispatchQueue = .init(label: "com.relax.offline.mp3.loadQueue", qos: .background) //下载任务会话 var session: SessionManager! //下载链接组 private var downloadURLs:[String: URL] = [:] //待下载任务队列组 private var downloadTasks: [String: (task: DownloadTask, status: MP_DownloadTaskStatus)] = [:] //活跃的下载任务队列组 private var queuedTaskVideoIds: [String] = [] // 用于存储每个任务的 backgroundTaskID private var backgroundTaskIDs: [String: UIBackgroundTaskIdentifier] = [:] //下载任务进度组 private var progressStorage: [String: CGFloat] = [:] // 新增进度存储 //音乐信息组 private var songHandlers:[String: MPPositive_SongItemModel] = [:] //下载文件夹名字 private var downloadDocumentName:String = "Downloads" // 添加一个标志位来控制循环结束 private var isDownloading: Bool = false //最大同时活跃下载任务数 private var maxTasksCount:Int = 6 private override init() { super.init() var configuration = SessionConfiguration() configuration.timeoutIntervalForRequest = 60 configuration.maxConcurrentTasksLimit = maxTasksCount configuration.allowsCellularAccess = true //设置会话 session = SessionManager("com.relax.offline.mp3.music.backgroundDownload", configuration: configuration, operationQueue: loadQueue) //创建下载文件夹 let downloadsURL = DocumentsURL.appendingPathComponent(downloadDocumentName) //检索下载文件夹是否存在 if !fileManager.fileExists(atPath: downloadsURL.path) { //不存在,新建下载文件夹 do{ //新建下载文件夹 try fileManager.createDirectory(at: downloadsURL, withIntermediateDirectories: true, attributes: nil) }catch { print("创建下载文件夹失败, 失败原因:\(error.localizedDescription)") } } } ///任务准备 func prepareVideoDownloadTask(from song:MPPositive_SongItemModel){ //根据歌曲信息获得下载链接,并创建下载任务(允许压缩下载) guard let url = URL(string: song.resourceUrls?.first ?? ""), let videoId = song.videoId else { //获取链接失败 MP_HUD.error("Download failed, please try again later!", delay: 2.0, completion: nil) //失败事件埋点 MP_AnalyticsManager.shared.player_b_downloadfailure_errorAction(song.videoId ?? "", videoname: song.title ?? "", artistname: song.shortBylineText ?? "", error: "Failed to create download link") return } if url.scheme == "file" { print("用户对同一首歌删除又下载") //出现这种情况是用户在播放器界面移除了下载歌曲,又继续点了下载同一首歌,当前歌曲资源的并未刷新,提供的路径资源仍旧是下载后的本地资源,需要重新调用网络请求获取 MP_NetWorkManager.shared.requestNextList("", videoId: videoId){ [weak self] listSongs in guard let self = self, let first = listSongs.first else { return } let group = DispatchGroup() group.enter() improveDataforLycirsAndRelated(first) {(result) in first.lyricsID = result.0 first.relatedID = result.1 group.leave() } group.enter() //补全资源路径组和封面路径组 improveDataforResouceAndCover(first) {(resourceUrls, coverUrls) in if let resourceUrls = resourceUrls { first.resourceUrls = resourceUrls.0 first.itags = resourceUrls.1 first.mimeTypes = resourceUrls.2 } first.coverUrls = coverUrls group.leave() } group.notify(queue: .main, execute: { [weak self] in guard let self = self else { return } //补全了数据,执行下载 prepareVideoDownloadTask(from: first) }) } return } //将歌曲模型加入歌曲组 songHandlers[videoId] = song //将下载链接加入链接组 downloadURLs[videoId] = url queuedTaskVideoIds.append(videoId) //执行下载任务 executeVideoDownloadTask() } //执行下载任务 private func executeVideoDownloadTask() { print("当前运行的任务数量: \(session.tasks.filter({ $0.status == .running }).count)") print("待下载队列: \(queuedTaskVideoIds)") while session.tasks.filter({ $0.status == .running }).count < maxTasksCount, let nextVideoId = queuedTaskVideoIds.first, let url = downloadURLs[nextVideoId] { queuedTaskVideoIds.removeFirst() //创建任务 let downloadTask = session.download(url, headers: ["Accept-Encoding": "gzip, deflate"])! //将下载任务加入任务队列 downloadTasks[nextVideoId] = (downloadTask, .queued) //开始执行任务 if let taskData = downloadTasks[nextVideoId], taskData.status == .queued { let task = taskData.task var bgTaskID: UIBackgroundTaskIdentifier = .invalid bgTaskID = UIApplication.shared.beginBackgroundTask(withName: "DownloadTask-\(nextVideoId)") { UIApplication.shared.endBackgroundTask(bgTaskID) self.backgroundTaskIDs[nextVideoId] = .invalid } self.backgroundTaskIDs[nextVideoId] = bgTaskID //下载任务进度值回调 task.progress { [weak self] (task) in guard let self = self else {return} if self.downloadTasks[nextVideoId]?.status == .queued { self.downloadTasks[nextVideoId]?.status = .downloading } //更新对应任务(videoId)的进度值 progressStorage[nextVideoId] = task.progress.fractionCompleted //执行下载进度更新代理 NotificationCenter.notificationKey.post(notificationName: .download_progress_source, object: ["videoId":nextVideoId]) } //下载任务完成状态回调 task.success {[weak self] (task) in //下载成功 guard let self = self else {return} //下载任务缓存地址 let filePathUrl:URL = URL(fileURLWithPath: task.filePath) print("任务下载地址:\(filePathUrl.path)") //获取下载文件夹地址 let downloadsURL = DocumentsURL.appendingPathComponent(downloadDocumentName) //创建真实下载地址 let fileURL = downloadsURL.appendingPathComponent("\(nextVideoId).mp4") //判断真实下载地址是否存在对应文件 if fileManager.fileExists(atPath: fileURL.path) { //确实存在一个文件,将它删除 try? fileManager.removeItem(at: fileURL) } //将任务缓存文件移动到真实下载地址 do{ try fileManager.moveItem(at: filePathUrl, to: fileURL) }catch{ //移动失败,终止任务 NotificationCenter.notificationKey.post(notificationName: .dowload_end_source, object: ["videoId":nextVideoId]) //发布报错埋点 MP_AnalyticsManager.shared.player_b_downloadfailure_errorAction(nextVideoId, videoname: songHandlers[nextVideoId]?.title ?? "", artistname: songHandlers[nextVideoId]?.shortBylineText ?? "", error: error.localizedDescription) //清除已失败任务的队列组内容 if self.downloadTasks[nextVideoId]?.status == .downloading { self.downloadTasks[nextVideoId]?.status = .failed } downloadURLs[nextVideoId] = nil progressStorage[nextVideoId] = nil songHandlers[nextVideoId] = nil MP_HUD.text("An error occurred while downloading. Please download again.", delay: 1.5, completion: nil) session.cancel(task) { _ in self.downloadTasks[nextVideoId] = nil print("移动\(self.songHandlers[nextVideoId]?.title ?? "")到真实下载地址失败,失败原因:\(error)") } //启动新的任务 self.executeVideoDownloadTask() return } //文件移动完成后,执行任务队列清理 saveLoadVideoItem(self.songHandlers[nextVideoId]!){ [weak self] in NotificationCenter.notificationKey.post(notificationName: .dowload_end_source, object: ["videoId":nextVideoId]) } // 清除已完成任务的进度记录 if self.downloadTasks[nextVideoId]?.status == .downloading { self.downloadTasks[nextVideoId]?.status = .finished } self.downloadURLs[nextVideoId] = nil self.downloadTasks[nextVideoId] = nil self.progressStorage[nextVideoId] = nil self.songHandlers[nextVideoId] = nil //告知用户下载成功 MP_HUD.downloadText("Download Successfull", delay: 1.0, completion: nil) //结束后台任务 if let bgTaskID = self.backgroundTaskIDs[nextVideoId] { UIApplication.shared.endBackgroundTask(bgTaskID) self.backgroundTaskIDs[nextVideoId] = .invalid } session.remove(task) //启动新的任务 self.executeVideoDownloadTask() }.failure { [weak self] task in //下载失败 guard let self = self else {return} //确定任务报错是否存在 if let error = task.error { //任务报错存在,处理完成状态回调 NotificationCenter.notificationKey.post(notificationName: .dowload_end_source, object: ["videoId":nextVideoId]) //发布报错埋点 MP_AnalyticsManager.shared.player_b_downloadfailure_errorAction(nextVideoId, videoname: songHandlers[nextVideoId]?.title ?? "", artistname: songHandlers[nextVideoId]?.shortBylineText ?? "", error: error.localizedDescription) //清除已失败任务的队列组内容 if self.downloadTasks[nextVideoId]?.status == .downloading { self.downloadTasks[nextVideoId]?.status = .failed } downloadURLs[nextVideoId] = nil progressStorage[nextVideoId] = nil songHandlers[nextVideoId] = nil MP_HUD.text("An error occurred while downloading. Please download again.", delay: 1.5, completion: nil) session.cancel(task) { _ in self.downloadTasks[nextVideoId] = nil print("\(self.songHandlers[nextVideoId]?.title ?? "")下载任务失败,失败原因:\(error)") //结束后台任务 if let bgTaskID = self.backgroundTaskIDs[nextVideoId] { UIApplication.shared.endBackgroundTask(bgTaskID) self.backgroundTaskIDs[nextVideoId] = .invalid } } //启动新的任务 self.executeVideoDownloadTask() } } } } } ///当前音乐是否存在于下载任务队列中 func isTasksQueue(for videoId:String) -> Bool { if let _ = songHandlers[videoId] { return true } return false } ///判断是否活跃下载任务 func isActiveTask(for videoId:String) -> Bool { if let taskData = downloadTasks[videoId] { return taskData.status == .downloading } return false } ///获取当前音乐任务下载进度 func getProgress(for videoId: String) -> CGFloat?{ return progressStorage[videoId] } func cancelAllTasksIfNeeded() { // 根据需求,取消所有任务,或者根据任务状态进行过滤 for key in progressStorage.keys { session.cancel(key) } } ///检索文件是否下载 func isDownloadedFileDocuments(_ videoId:String, completion: @escaping (Bool) -> Void){ //优先检索数据库模型是否存在 MPPositive_DownloadItemModel.fetch(predicate: .init(format: "videoId == %@", videoId)) { [weak self] results in if results.isEmpty { // 数据库中没有记录,直接返回 false DispatchQueue.main.async { completion(false) } return } //数据库中存在数据 DispatchQueue.global(qos: .userInitiated).async { let fileURL = self?.getDocumentsFileURL(videoId) // 回到主线程调用回调闭包 DispatchQueue.main.async { completion(fileURL != nil) } } } } ///获取沙盒下载路径 func getDocumentsFileURL(_ videoID: String) -> String? { // 获取Documents目录的URL let documentsDirectoryURL = DocumentsURL.appendingPathComponent("Downloads") // 根据videoId构建文件完整URL let fileURL = documentsDirectoryURL.appendingPathComponent("\(videoID).mp4") //检索是否存在 if FileManager.default.fileExists(atPath: fileURL.path) == true { //存在 return fileURL.absoluteString }else { return nil } } ///取消下载 func cancelDownloadTask(_ videoId:String, completion:((String) -> Void)?) { DispatchQueue.main.async { [weak self] in guard let self = self else {return} //发布报错埋点 MP_AnalyticsManager.shared.player_b_downloadfailure_errorAction(videoId, videoname: songHandlers[videoId]?.title ?? "", artistname: songHandlers[videoId]?.shortBylineText ?? "", error: "User Cancel Task") queuedTaskVideoIds.removeAll(where: {$0 == videoId}) //清除已失败任务的队列组内容 if self.downloadTasks[videoId]?.status == .downloading { self.downloadTasks[videoId]?.status = .failed } downloadURLs[videoId] = nil progressStorage[videoId] = nil songHandlers[videoId] = nil if let task = self.downloadTasks[videoId]?.task { session.cancel(task) { _ in self.downloadTasks[videoId] = nil print("\(self.songHandlers[videoId]?.title ?? "")下载任务失败,失败原因:用户取消了") } } //结束后台任务 if let bgTaskID = self.backgroundTaskIDs[videoId] { UIApplication.shared.endBackgroundTask(bgTaskID) self.backgroundTaskIDs[videoId] = .invalid } deleteFileDocuments(videoId, completion: nil) if completion != nil { completion!(videoId) } } } ///删除下载文件 func deleteFileDocuments(_ videoId:String, completion:((String) -> Void)?) { DispatchQueue.main.async { [weak self] in guard let self = self else {return} //移除数据库下载模型 MPPositive_DownloadItemModel.fetch(predicate: .init(format: "videoId == %@", videoId)) { results in results.forEach { item in if item.videoId == videoId { MPPositive_DownloadItemModel.delete(item) } } MPPositive_LoadCoreModel.shared.reloadLoadSongViewModel(nil) let downloadsURL = self.DocumentsURL.appendingPathComponent("Downloads") let fileURL = downloadsURL.appendingPathComponent("\(videoId).mp4") if self.fileManager.fileExists(atPath: fileURL.path) { do{ try FileManager.default.removeItem(at: fileURL) //文件删除成功 if completion != nil { completion!(videoId) } print("成功删除了\(videoId)文件") }catch{ //文件删除成功 if completion != nil { completion!(videoId) } print("删除文件时发生错误:\(error)") } }else { //文件删除成功 if completion != nil { completion!(videoId) } print("文件不存在") } } } } }