848 lines
33 KiB
Swift
848 lines
33 KiB
Swift
//
|
|
// ModelDownloadManager.swift
|
|
// MNNLLMiOS
|
|
//
|
|
// Created by 游薪渝(揽清) on 2025/8/27.
|
|
//
|
|
|
|
import Foundation
|
|
|
|
/// ModelDownloadManager - Advanced concurrent model download manager
|
|
///
|
|
/// This actor-based download manager provides high-performance, resumable downloads
|
|
/// with intelligent chunking, dynamic concurrency optimization, and comprehensive
|
|
/// error handling for model files from various sources.
|
|
///
|
|
/// Key Features:
|
|
/// - Concurrent downloads with dynamic concurrency adjustment
|
|
/// - Intelligent file chunking for large files (>50MB)
|
|
/// - Resume capability with partial download preservation
|
|
/// - Exponential backoff retry mechanism
|
|
/// - Real-time progress tracking with throttling
|
|
/// - Memory-efficient streaming downloads
|
|
/// - Thread-safe operations using Swift Actor model
|
|
///
|
|
/// Architecture:
|
|
/// - Uses URLSession for network operations
|
|
/// - Implements semaphore-based concurrency control
|
|
/// - Supports both direct and chunked download strategies
|
|
/// - Maintains download state persistence
|
|
///
|
|
/// Performance Optimizations:
|
|
/// - Dynamic chunk size calculation based on network conditions
|
|
/// - Optimal concurrency level determination
|
|
/// - Progress update throttling to prevent UI blocking
|
|
/// - Temporary file management for resume functionality
|
|
///
|
|
/// Usage:
|
|
/// ```swift
|
|
/// let manager = ModelDownloadManager(
|
|
/// repoPath: "owner/model-name",
|
|
/// source: .modelScope
|
|
/// )
|
|
/// try await manager.downloadModel(
|
|
/// to: "models",
|
|
/// modelId: "example-model",
|
|
/// modelName: "ExampleModel"
|
|
/// ) { progress in
|
|
/// print("Progress: \(progress * 100)%")
|
|
/// }
|
|
/// ```
|
|
@available(iOS 13.4, macOS 10.15, *)
|
|
public actor ModelDownloadManager: ModelDownloadManagerProtocol {
|
|
// MARK: - Properties
|
|
|
|
private let repoPath: String
|
|
private var session: URLSession
|
|
private let sessionConfig: URLSessionConfiguration
|
|
private var isSessionInvalidated = false
|
|
private let fileManager: FileManager
|
|
private let storage: ModelDownloadStorage
|
|
private let config: DownloadConfig
|
|
private let source: ModelSource
|
|
|
|
private let concurrencyManager: DynamicConcurrencyManager
|
|
private var downloadSemaphore: AsyncSemaphore
|
|
private var downloadChunkSemaphore: AsyncSemaphore?
|
|
private var downloadQueue: [DownloadTask] = []
|
|
|
|
private var progress = DownloadProgress()
|
|
private var progressCallback: ((Double) -> Void)?
|
|
|
|
private var isCancelled: Bool = false
|
|
|
|
// MARK: - Initialization
|
|
|
|
/// Initializes a new ModelDownloadManager with configurable parameters
|
|
///
|
|
/// - Parameters:
|
|
/// - repoPath: Repository path in format "owner/model-name"
|
|
/// - config: Download configuration including retry and chunk settings
|
|
/// - sessionConfig: URLSession configuration for network requests
|
|
/// - enableLogging: Whether to enable detailed logging
|
|
/// - source: Model source platform (ModelScope, Modeler, etc.)
|
|
/// - concurrencyConfig: Dynamic concurrency management configuration
|
|
public init(
|
|
repoPath: String,
|
|
config: DownloadConfig = .default,
|
|
sessionConfig: URLSessionConfiguration = .default,
|
|
enableLogging: Bool = true,
|
|
source: ModelSource,
|
|
concurrencyConfig: DynamicConcurrencyConfig = .default
|
|
) {
|
|
self.repoPath = repoPath
|
|
self.config = config
|
|
self.source = source
|
|
self.sessionConfig = sessionConfig
|
|
fileManager = .default
|
|
storage = ModelDownloadStorage()
|
|
session = URLSession(configuration: sessionConfig)
|
|
concurrencyManager = DynamicConcurrencyManager(config: concurrencyConfig)
|
|
downloadSemaphore = AsyncSemaphore(value: config.maxConcurrentDownloads)
|
|
print("ModelClient init")
|
|
|
|
ModelDownloadLogger.isEnabled = enableLogging
|
|
}
|
|
|
|
// MARK: - Public Methods
|
|
|
|
/// Downloads a complete model with all its files
|
|
///
|
|
/// This method orchestrates the entire download process including file discovery,
|
|
/// task preparation, concurrent execution, and progress tracking. It supports
|
|
/// resume functionality and handles various error conditions gracefully.
|
|
///
|
|
/// - Parameters:
|
|
/// - destinationFolder: Base folder for download (relative to Documents)
|
|
/// - modelId: Unique identifier for the model
|
|
/// - modelName: Display name used for folder creation
|
|
/// - progress: Optional progress callback (0.0 to 1.0)
|
|
/// - Throws: ModelScopeError for various download failures
|
|
public func downloadModel(
|
|
to destinationFolder: String = "",
|
|
modelId: String,
|
|
modelName: String,
|
|
progress: ((Double) -> Void)? = nil
|
|
) async throws {
|
|
progressCallback = progress
|
|
|
|
// Ensure we have a valid session and reset cancelled state
|
|
await ensureValidSession()
|
|
|
|
ModelDownloadLogger.info("Starting optimized download for modelId: \(modelId)")
|
|
|
|
let destination = try resolveDestinationPath(base: destinationFolder, modelId: modelName)
|
|
ModelDownloadLogger.info("Will download to: \(destination)")
|
|
|
|
let files = try await fetchFileList(root: "", revision: "")
|
|
|
|
// Calculate total size and prepare download tasks
|
|
try await prepareDownloadTasks(files: files, destinationPath: destination)
|
|
|
|
// Start concurrent downloads
|
|
try await executeDownloads()
|
|
|
|
// Check if download was cancelled during execution
|
|
if isCancelled {
|
|
ModelDownloadLogger.info("Download was cancelled, maintaining current progress state")
|
|
throw ModelScopeError.downloadCancelled
|
|
}
|
|
|
|
await updateProgress(1.0)
|
|
ModelDownloadLogger.info("Download completed successfully")
|
|
}
|
|
|
|
/// Cancels all ongoing download operations while preserving partial downloads
|
|
///
|
|
/// This method gracefully stops all active downloads and preserves temporary
|
|
/// files to enable resume functionality in future download attempts.
|
|
public func cancelDownload() async {
|
|
isCancelled = true
|
|
|
|
// Cancel all session tasks but don't invalidate the session
|
|
session.getAllTasks { tasks in
|
|
for task in tasks {
|
|
task.cancel()
|
|
}
|
|
}
|
|
|
|
ModelDownloadLogger.info("Download cancelled, temporary files preserved for resume")
|
|
}
|
|
|
|
// MARK: - Private Methods - Task Preparation
|
|
|
|
/// Prepares download tasks by analyzing files and creating appropriate download strategies
|
|
///
|
|
/// This method processes the file list and determines the optimal download approach
|
|
/// for each file based on size, existing partial downloads, and configuration.
|
|
///
|
|
/// - Parameters:
|
|
/// - files: Array of ModelFile objects representing files to download
|
|
/// - destinationPath: Target directory path for downloads
|
|
/// - Throws: ModelScopeError for file system or processing errors
|
|
private func prepareDownloadTasks(
|
|
files: [ModelFile],
|
|
destinationPath: String
|
|
) async throws {
|
|
downloadQueue.removeAll()
|
|
progress = DownloadProgress()
|
|
|
|
try await processFiles(files, destinationPath: destinationPath)
|
|
|
|
progress.totalFiles = downloadQueue.count
|
|
ModelDownloadLogger.info("Prepared \(downloadQueue.count) download tasks, total size: \(progress.totalBytes) bytes")
|
|
}
|
|
|
|
/// Processes individual files and creates corresponding download tasks
|
|
///
|
|
/// Analyzes each file to determine if it needs chunked or direct download
|
|
/// based on file size and configuration thresholds. Handles directory creation
|
|
/// and recursive file processing for nested structures.
|
|
///
|
|
/// - Parameters:
|
|
/// - files: Array of ModelFile objects to process
|
|
/// - destinationPath: Base destination path for downloads
|
|
/// - Throws: ModelScopeError for file system or network errors
|
|
private func processFiles(
|
|
_ files: [ModelFile],
|
|
destinationPath: String
|
|
) async throws {
|
|
for file in files {
|
|
if file.type == "tree" {
|
|
let newPath = (destinationPath as NSString)
|
|
.appendingPathComponent(file.name.sanitizedPath)
|
|
try fileManager.createDirectoryIfNeeded(at: newPath)
|
|
|
|
let subFiles = try await fetchFileList(root: file.path, revision: "")
|
|
try await processFiles(subFiles, destinationPath: newPath)
|
|
} else if file.type == "blob" {
|
|
// Initialize progress tracking for all files
|
|
await initializeFileProgress(fileName: file.name, totalBytes: Int64(file.size))
|
|
progress.totalBytes += Int64(file.size)
|
|
|
|
if !storage.isFileDownloaded(file, at: destinationPath) {
|
|
var task = DownloadTask(
|
|
file: file,
|
|
destinationPath: destinationPath,
|
|
priority: .medium
|
|
)
|
|
|
|
// Check if file should be chunked
|
|
if Int64(file.size) > config.largeFileThreshold {
|
|
task.chunks = await createChunks(for: file)
|
|
ModelDownloadLogger.info("File \(file.name) will be downloaded in \(task.chunks.count) chunks")
|
|
}
|
|
|
|
downloadQueue.append(task)
|
|
} else {
|
|
// File already downloaded, mark as completed in progress tracking
|
|
if var fileProgress = progress.fileProgress[file.name] {
|
|
fileProgress.downloadedBytes = fileProgress.totalBytes
|
|
fileProgress.isCompleted = true
|
|
progress.fileProgress[file.name] = fileProgress
|
|
}
|
|
ModelDownloadLogger.info("File \(file.name) already exists, skipping download")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Creates chunked download information for large files
|
|
///
|
|
/// Divides large files into optimal chunks for concurrent downloading,
|
|
/// calculates resume offsets for existing partial downloads, and creates
|
|
/// chunk metadata with temporary file locations.
|
|
///
|
|
/// - Parameter file: ModelFile object representing the file to chunk
|
|
/// - Returns: Array of ChunkInfo objects containing chunk metadata
|
|
private func createChunks(for file: ModelFile) async -> [ChunkInfo] {
|
|
let fileSize = Int64(file.size)
|
|
|
|
let recommendedChunkSize = await concurrencyManager.recommendedChunkSize()
|
|
let chunkSize = min(recommendedChunkSize, config.chunkSize)
|
|
|
|
let chunkCount = Int(ceil(Double(fileSize) / Double(chunkSize)))
|
|
var chunks: [ChunkInfo] = []
|
|
|
|
ModelDownloadLogger.info("File \(file.name): using chunk size \(chunkSize / 1024 / 1024)MB, total \(chunkCount) chunks")
|
|
|
|
for i in 0 ..< chunkCount {
|
|
let startOffset = Int64(i) * chunkSize
|
|
let endOffset = min(startOffset + chunkSize - 1, fileSize - 1)
|
|
|
|
let modelHash = repoPath.stableHash
|
|
let fileHash = file.path.stableHash
|
|
|
|
let documentsURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!
|
|
let downloadsURL = documentsURL.appendingPathComponent(".downloads", isDirectory: true)
|
|
let tempURL = downloadsURL
|
|
.appendingPathComponent("model_\(modelHash)_file_\(fileHash)_chunk_\(i)_\(file.name.sanitizedPath).tmp")
|
|
|
|
// Check if chunk already exists and calculate downloaded bytes
|
|
var downloadedBytes: Int64 = 0
|
|
var isCompleted = false
|
|
|
|
if fileManager.fileExists(atPath: tempURL.path) {
|
|
do {
|
|
let attributes = try fileManager.attributesOfItem(atPath: tempURL.path)
|
|
downloadedBytes = attributes[.size] as? Int64 ?? 0
|
|
let expectedChunkSize = endOffset - startOffset + 1
|
|
isCompleted = downloadedBytes >= expectedChunkSize
|
|
} catch {
|
|
ModelDownloadLogger.error("Failed to get chunk attributes: \(error)")
|
|
}
|
|
}
|
|
|
|
chunks.append(ChunkInfo(
|
|
index: i,
|
|
startOffset: startOffset,
|
|
endOffset: endOffset,
|
|
tempURL: tempURL,
|
|
isCompleted: isCompleted,
|
|
downloadedBytes: downloadedBytes
|
|
))
|
|
}
|
|
|
|
return chunks
|
|
}
|
|
|
|
/// Calculate initial progress from existing downloaded files and chunks
|
|
private func calculateInitialProgress() async {
|
|
for task in downloadQueue {
|
|
if task.isChunked {
|
|
// For chunked files, sum up downloaded bytes from all chunks
|
|
let chunkBytes = task.chunks.reduce(0) { total, chunk in
|
|
total + (chunk.isCompleted ? (chunk.endOffset - chunk.startOffset + 1) : chunk.downloadedBytes)
|
|
}
|
|
|
|
// Update file progress with chunk data
|
|
if var fileProgress = progress.fileProgress[task.file.name] {
|
|
fileProgress.downloadedBytes = chunkBytes
|
|
progress.fileProgress[task.file.name] = fileProgress
|
|
}
|
|
}
|
|
// For non-chunked files, if they exist, they would not be in downloadQueue
|
|
}
|
|
|
|
let totalDownloadedBytes = progress.fileProgress.values.reduce(0) { sum, fileProgress in
|
|
sum + fileProgress.downloadedBytes
|
|
}
|
|
|
|
ModelDownloadLogger.info("Initial downloaded bytes: \(totalDownloadedBytes)")
|
|
}
|
|
|
|
// MARK: - Download Execution
|
|
|
|
/// Executes download tasks with dynamic concurrency management
|
|
///
|
|
/// Manages the concurrent execution of download tasks using semaphores
|
|
/// and dynamic concurrency adjustment based on network performance.
|
|
/// Handles both chunked and direct download strategies.
|
|
///
|
|
/// - Throws: ModelScopeError if downloads fail or are cancelled
|
|
private func executeDownloads() async throws {
|
|
// Calculate initial downloaded bytes from existing files and chunks
|
|
await calculateInitialProgress()
|
|
|
|
await withTaskGroup(of: Void.self) { group in
|
|
for task in downloadQueue {
|
|
if isCancelled { break }
|
|
|
|
group.addTask {
|
|
await self.downloadSemaphore.wait()
|
|
defer { Task { await self.downloadSemaphore.signal() } }
|
|
|
|
do {
|
|
if task.isChunked {
|
|
try await self.downloadFileInChunks(task: task)
|
|
} else {
|
|
try await self.downloadFileDirect(task: task)
|
|
}
|
|
|
|
// Only mark as completed if not cancelled
|
|
if await !self.isCancelled {
|
|
await self.markFileCompleted(task: task)
|
|
}
|
|
} catch {
|
|
if await !self.isCancelled {
|
|
ModelDownloadLogger.error("Failed to download \(task.file.name): \(error)")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Downloads a file using chunked strategy with resume capability
|
|
///
|
|
/// Handles the download of individual file chunks with retry logic,
|
|
/// progress tracking, and automatic chunk merging upon completion.
|
|
/// Uses optimal concurrency for chunk downloads.
|
|
///
|
|
/// - Parameter task: DownloadTask containing chunk information and file metadata
|
|
/// - Throws: ModelScopeError for network or file system errors
|
|
private func downloadFileInChunks(task: DownloadTask) async throws {
|
|
ModelDownloadLogger.info("Starting chunked download for: \(task.file.name)")
|
|
|
|
let concurrencyCount = await concurrencyManager.calculateOptimalConcurrency(chunkCount: task.chunks.count)
|
|
|
|
downloadChunkSemaphore = AsyncSemaphore(value: concurrencyCount)
|
|
|
|
ModelDownloadLogger.info("Using optimal concurrency: \(concurrencyCount) for \(task.chunks.count) chunks")
|
|
|
|
// Check if any chunks are already completed and log progress (but don't update global progress yet)
|
|
let completedBytes = task.chunks.reduce(0) { total, chunk in
|
|
total + (chunk.isCompleted ? (chunk.endOffset - chunk.startOffset + 1) : chunk.downloadedBytes)
|
|
}
|
|
if completedBytes > 0 {
|
|
ModelDownloadLogger.info("Found \(completedBytes) bytes already downloaded for \(task.file.name)")
|
|
}
|
|
|
|
try await withThrowingTaskGroup(of: Void.self) { group in
|
|
for chunk in task.chunks {
|
|
if isCancelled { break }
|
|
await self.downloadChunkSemaphore?.wait()
|
|
defer { Task { await self.downloadChunkSemaphore?.signal() } }
|
|
|
|
if !chunk.isCompleted {
|
|
group.addTask {
|
|
try await self.downloadChunk(chunk: chunk, file: task.file)
|
|
}
|
|
}
|
|
}
|
|
|
|
try await group.waitForAll()
|
|
}
|
|
|
|
if isCancelled {
|
|
throw ModelScopeError.downloadCancelled
|
|
}
|
|
|
|
// Merge chunks
|
|
try await mergeChunks(task: task)
|
|
}
|
|
|
|
/// Downloads a specific chunk with range requests and resume support
|
|
///
|
|
/// Performs HTTP range request to download a specific portion of a file,
|
|
/// with automatic resume from existing partial downloads and exponential
|
|
/// backoff retry logic.
|
|
///
|
|
/// - Parameters:
|
|
/// - chunk: ChunkInfo containing chunk metadata and temporary file location
|
|
/// - file: ModelFile object representing the source file
|
|
/// - Throws: ModelScopeError for network or file system errors
|
|
private func downloadChunk(chunk: ChunkInfo, file: ModelFile) async throws {
|
|
if chunk.isCompleted {
|
|
ModelDownloadLogger.info("Chunk \(chunk.index) already completed, skipping")
|
|
return
|
|
}
|
|
|
|
var lastError: Error?
|
|
|
|
// Retry logic with exponential backoff
|
|
for attempt in 0 ..< config.maxRetries {
|
|
do {
|
|
let url = try buildDownloadURL(for: file)
|
|
var request = URLRequest(url: url)
|
|
|
|
// Calculate resume offset within this chunk
|
|
let resumeOffset = chunk.startOffset + chunk.downloadedBytes
|
|
let remainingEndOffset = chunk.endOffset
|
|
|
|
// Set Range header for resumable download
|
|
if chunk.downloadedBytes > 0 {
|
|
request.setValue("bytes=\(resumeOffset)-\(remainingEndOffset)", forHTTPHeaderField: "Range")
|
|
ModelDownloadLogger.info("Resuming chunk \(chunk.index) from offset \(chunk.downloadedBytes)")
|
|
} else {
|
|
request.setValue("bytes=\(chunk.startOffset)-\(chunk.endOffset)", forHTTPHeaderField: "Range")
|
|
}
|
|
|
|
let (asyncBytes, response) = try await session.bytes(for: request)
|
|
try validateResponse(response)
|
|
|
|
// Create or open file handle for writing
|
|
if chunk.downloadedBytes == 0, !fileManager.fileExists(atPath: chunk.tempURL.path) {
|
|
fileManager.createFile(atPath: chunk.tempURL.path, contents: nil, attributes: nil)
|
|
}
|
|
|
|
let fileHandle = try FileHandle(forWritingTo: chunk.tempURL)
|
|
defer { try? fileHandle.close() }
|
|
|
|
if chunk.downloadedBytes > 0 {
|
|
try fileHandle.seekToEnd()
|
|
}
|
|
|
|
var buffer = Data()
|
|
buffer.reserveCapacity(512 * 1024) // Reserve 512KB buffer for chunks
|
|
|
|
for try await byte in asyncBytes {
|
|
if isCancelled { throw ModelScopeError.downloadCancelled }
|
|
|
|
buffer.append(byte)
|
|
|
|
// Write in larger chunks to reduce I/O operations
|
|
if buffer.count >= 128 * 1024 { // 128KB chunks for chunk downloads
|
|
try fileHandle.write(contentsOf: buffer)
|
|
await updateFileProgress(fileName: file.name, bytes: Int64(buffer.count))
|
|
buffer.removeAll(keepingCapacity: true)
|
|
}
|
|
}
|
|
|
|
// Write remaining buffer
|
|
if !buffer.isEmpty {
|
|
try fileHandle.write(contentsOf: buffer)
|
|
await updateFileProgress(fileName: file.name, bytes: Int64(buffer.count))
|
|
}
|
|
|
|
ModelDownloadLogger.info("Chunk \(chunk.index) downloaded successfully")
|
|
|
|
return // Success, exit retry loop
|
|
|
|
} catch {
|
|
lastError = error
|
|
ModelDownloadLogger.error("Chunk \(chunk.index) download attempt \(attempt + 1) failed: \(error)")
|
|
|
|
// Don't retry if cancelled
|
|
if isCancelled {
|
|
throw ModelScopeError.downloadCancelled
|
|
}
|
|
|
|
// Don't wait after the last attempt
|
|
if attempt < config.maxRetries - 1 {
|
|
let delay = config.retryDelay * pow(2.0, Double(attempt)) // Exponential backoff
|
|
ModelDownloadLogger.info("Retrying chunk \(chunk.index) in \(delay) seconds...")
|
|
try await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
|
|
}
|
|
}
|
|
}
|
|
|
|
// All retries failed
|
|
throw lastError ?? ModelScopeError.downloadFailed(NSError(
|
|
domain: "ModelScope",
|
|
code: -1,
|
|
userInfo: [NSLocalizedDescriptionKey: "All chunk download attempts failed"]
|
|
))
|
|
}
|
|
|
|
/// Downloads a file directly without chunking
|
|
///
|
|
/// Performs a complete file download in a single request with resume
|
|
/// capability and progress tracking for smaller files. Uses exponential
|
|
/// backoff retry mechanism for failed attempts.
|
|
///
|
|
/// - Parameter task: DownloadTask containing file information and destination
|
|
/// - Throws: ModelScopeError for network or file system errors
|
|
private func downloadFileDirect(task: DownloadTask) async throws {
|
|
ModelDownloadLogger.info("downloadFileDirect \(task.file.name)")
|
|
|
|
let file = task.file
|
|
let destination = URL(fileURLWithPath: task.destinationPath)
|
|
.appendingPathComponent(file.name.sanitizedPath)
|
|
|
|
let modelHash = repoPath.stableHash
|
|
let fileHash = file.path.stableHash
|
|
|
|
let documentsURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!
|
|
let downloadsURL = documentsURL.appendingPathComponent(".downloads", isDirectory: true)
|
|
|
|
try? fileManager.createDirectory(at: downloadsURL, withIntermediateDirectories: true, attributes: nil)
|
|
|
|
let tempURL = downloadsURL
|
|
.appendingPathComponent("model_\(modelHash)_file_\(fileHash)_\(file.name.sanitizedPath).tmp")
|
|
|
|
var lastError: Error?
|
|
|
|
// Retry logic with exponential backoff
|
|
for attempt in 0 ..< config.maxRetries {
|
|
do {
|
|
// Check for resume
|
|
var resumeOffset: Int64 = 0
|
|
if fileManager.fileExists(atPath: tempURL.path) {
|
|
let attributes = try fileManager.attributesOfItem(atPath: tempURL.path)
|
|
resumeOffset = attributes[.size] as? Int64 ?? 0
|
|
} else {
|
|
try Data().write(to: tempURL)
|
|
}
|
|
|
|
let url = try buildDownloadURL(for: file)
|
|
var request = URLRequest(url: url)
|
|
if resumeOffset > 0 {
|
|
request.setValue("bytes=\(resumeOffset)-", forHTTPHeaderField: "Range")
|
|
}
|
|
|
|
let (asyncBytes, response) = try await session.bytes(for: request)
|
|
try validateResponse(response)
|
|
|
|
let fileHandle = try FileHandle(forWritingTo: tempURL)
|
|
defer { try? fileHandle.close() }
|
|
|
|
if resumeOffset > 0 {
|
|
try fileHandle.seek(toOffset: UInt64(resumeOffset))
|
|
}
|
|
|
|
var downloadedBytes: Int64 = resumeOffset
|
|
var buffer = Data()
|
|
buffer.reserveCapacity(1024 * 1024) // Reserve 1MB buffer
|
|
|
|
for try await byte in asyncBytes {
|
|
if isCancelled { throw ModelScopeError.downloadCancelled }
|
|
|
|
buffer.append(byte)
|
|
|
|
// Write in larger chunks to reduce I/O operations
|
|
if buffer.count >= 256 * 1024 { // 256KB chunks
|
|
try fileHandle.write(contentsOf: buffer)
|
|
downloadedBytes += Int64(buffer.count)
|
|
await updateFileProgress(fileName: file.name, bytes: Int64(buffer.count))
|
|
buffer.removeAll(keepingCapacity: true)
|
|
}
|
|
}
|
|
|
|
// Write remaining buffer
|
|
if !buffer.isEmpty {
|
|
try fileHandle.write(contentsOf: buffer)
|
|
downloadedBytes += Int64(buffer.count)
|
|
await updateFileProgress(fileName: file.name, bytes: Int64(buffer.count))
|
|
}
|
|
|
|
// Progress already updated in the loop above
|
|
|
|
// Move to final destination
|
|
if fileManager.fileExists(atPath: destination.path) {
|
|
try fileManager.removeItem(at: destination)
|
|
}
|
|
try fileManager.moveItem(at: tempURL, to: destination)
|
|
|
|
ModelDownloadLogger.info("File \(file.name) downloaded successfully")
|
|
return // Success, exit retry loop
|
|
|
|
} catch {
|
|
lastError = error
|
|
ModelDownloadLogger.error("File \(file.name) download attempt \(attempt + 1) failed: \(error)")
|
|
|
|
// Don't retry if cancelled
|
|
if isCancelled {
|
|
throw ModelScopeError.downloadCancelled
|
|
}
|
|
|
|
// Don't wait after the last attempt
|
|
if attempt < config.maxRetries - 1 {
|
|
let delay = config.retryDelay * pow(2.0, Double(attempt)) // Exponential backoff
|
|
ModelDownloadLogger.info("Retrying file \(file.name) in \(delay) seconds...")
|
|
try await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
|
|
}
|
|
}
|
|
}
|
|
|
|
// All retries failed
|
|
throw lastError ?? ModelScopeError.downloadFailed(NSError(
|
|
domain: "ModelScope",
|
|
code: -1,
|
|
userInfo: [NSLocalizedDescriptionKey: "All file download attempts failed"]
|
|
))
|
|
}
|
|
|
|
/// Merges downloaded chunks into the final file
|
|
///
|
|
/// Combines all downloaded chunks in the correct order to create the
|
|
/// final file, then cleans up temporary chunk files.
|
|
///
|
|
/// - Parameter task: DownloadTask containing chunk information and destination
|
|
/// - Throws: ModelScopeError for file system errors during merging
|
|
private func mergeChunks(task: DownloadTask) async throws {
|
|
let destination = URL(fileURLWithPath: task.destinationPath)
|
|
.appendingPathComponent(task.file.name.sanitizedPath)
|
|
|
|
// Create destination file if it doesn't exist
|
|
if !fileManager.fileExists(atPath: destination.path) {
|
|
fileManager.createFile(atPath: destination.path, contents: nil, attributes: nil)
|
|
}
|
|
|
|
let finalFileHandle = try FileHandle(forWritingTo: destination)
|
|
defer { try? finalFileHandle.close() }
|
|
|
|
// Sort chunks by index and merge
|
|
let sortedChunks = task.chunks.sorted { $0.index < $1.index }
|
|
|
|
for chunk in sortedChunks {
|
|
if isCancelled { throw ModelScopeError.downloadCancelled }
|
|
|
|
let chunkData = try Data(contentsOf: chunk.tempURL)
|
|
try finalFileHandle.write(contentsOf: chunkData)
|
|
|
|
// Clean up chunk file
|
|
try? fileManager.removeItem(at: chunk.tempURL)
|
|
}
|
|
|
|
ModelDownloadLogger.info("Successfully merged \(sortedChunks.count) chunks for \(task.file.name)")
|
|
}
|
|
|
|
// MARK: - Helper Methods
|
|
|
|
private func ensureValidSession() async {
|
|
if isSessionInvalidated {
|
|
// Create a new session if the previous one was invalidated
|
|
session = URLSession(configuration: sessionConfig)
|
|
isSessionInvalidated = false
|
|
ModelDownloadLogger.info("Created new URLSession after previous invalidation")
|
|
}
|
|
|
|
// Always reset the cancelled flag when ensuring valid session
|
|
isCancelled = false
|
|
}
|
|
|
|
private func buildDownloadURL(for file: ModelFile) throws -> URL {
|
|
if source == .modelScope {
|
|
return try buildURL(
|
|
path: "/repo",
|
|
queryItems: [
|
|
URLQueryItem(name: "Revision", value: "master"),
|
|
URLQueryItem(name: "FilePath", value: file.path),
|
|
]
|
|
)
|
|
} else {
|
|
return try buildModelerURL(
|
|
path: file.path,
|
|
queryItems: []
|
|
)
|
|
}
|
|
}
|
|
|
|
private func markFileCompleted(task: DownloadTask) async {
|
|
progress.completedFiles += 1
|
|
|
|
// Mark file as completed in progress tracking
|
|
if var fileProgress = progress.fileProgress[task.file.name] {
|
|
fileProgress.downloadedBytes = fileProgress.totalBytes
|
|
fileProgress.isCompleted = true
|
|
progress.fileProgress[task.file.name] = fileProgress
|
|
}
|
|
|
|
storage.saveFileStatus(task.file, at: task.destinationPath)
|
|
ModelDownloadLogger.info("Completed: \(task.file.name) (\(progress.completedFiles)/\(progress.totalFiles))")
|
|
|
|
await updateProgress(progress.progress)
|
|
}
|
|
|
|
private func updateFileProgress(fileName: String, bytes: Int64) async {
|
|
if var fileProgress = progress.fileProgress[fileName] {
|
|
fileProgress.downloadedBytes = min(fileProgress.downloadedBytes + bytes, fileProgress.totalBytes)
|
|
progress.fileProgress[fileName] = fileProgress
|
|
|
|
let newProgress = progress.progress
|
|
let progressDiff = abs(newProgress - progress.lastReportedProgress)
|
|
if progressDiff >= 0.001 || newProgress >= 1.0 {
|
|
progress.lastReportedProgress = newProgress
|
|
await updateProgress(newProgress)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func initializeFileProgress(fileName: String, totalBytes: Int64) async {
|
|
let fileProgress = FileDownloadProgress(
|
|
fileName: fileName,
|
|
totalBytes: totalBytes,
|
|
downloadedBytes: 0,
|
|
isCompleted: false
|
|
)
|
|
progress.fileProgress[fileName] = fileProgress
|
|
}
|
|
|
|
private func updateProgress(_ value: Double) async {
|
|
guard let callback = progressCallback else { return }
|
|
|
|
Task { @MainActor in
|
|
callback(value)
|
|
}
|
|
}
|
|
|
|
// MARK: - Network Methods
|
|
|
|
private func fetchFileList(
|
|
root: String,
|
|
revision: String
|
|
) async throws -> [ModelFile] {
|
|
let url = try buildURL(
|
|
path: "/repo/files",
|
|
queryItems: [
|
|
URLQueryItem(name: "Root", value: root),
|
|
URLQueryItem(name: "Revision", value: revision),
|
|
]
|
|
)
|
|
let (data, response) = try await session.data(from: url)
|
|
try validateResponse(response)
|
|
|
|
let modelResponse = try JSONDecoder().decode(ModelResponse.self, from: data)
|
|
return modelResponse.data.files
|
|
}
|
|
|
|
private func buildURL(
|
|
path: String,
|
|
queryItems: [URLQueryItem]
|
|
) throws -> URL {
|
|
var components = URLComponents()
|
|
components.scheme = "https"
|
|
components.host = "modelscope.cn"
|
|
components.path = "/api/v1/models/\(repoPath)\(path)"
|
|
components.queryItems = queryItems
|
|
|
|
guard let url = components.url else {
|
|
throw ModelScopeError.invalidURL
|
|
}
|
|
return url
|
|
}
|
|
|
|
private func buildModelerURL(
|
|
path: String,
|
|
queryItems: [URLQueryItem]
|
|
) throws -> URL {
|
|
var components = URLComponents()
|
|
components.scheme = "https"
|
|
components.host = "modelers.cn"
|
|
components.path = "/coderepo/web/v1/file/\(repoPath)/main/media/\(path)"
|
|
components.queryItems = queryItems
|
|
|
|
guard let url = components.url else {
|
|
throw ModelScopeError.invalidURL
|
|
}
|
|
return url
|
|
}
|
|
|
|
private func validateResponse(_ response: URLResponse) throws {
|
|
guard let httpResponse = response as? HTTPURLResponse,
|
|
(200 ... 299).contains(httpResponse.statusCode)
|
|
else {
|
|
throw ModelScopeError.invalidResponse
|
|
}
|
|
}
|
|
|
|
private func resolveDestinationPath(
|
|
base: String,
|
|
modelId: String
|
|
) throws -> String {
|
|
guard let documentsPath = FileManager.default
|
|
.urls(for: .documentDirectory, in: .userDomainMask)
|
|
.first
|
|
else {
|
|
throw ModelScopeError.fileSystemError(
|
|
NSError(domain: "ModelScope", code: -1, userInfo: [
|
|
NSLocalizedDescriptionKey: "Cannot access Documents directory",
|
|
])
|
|
)
|
|
}
|
|
|
|
let components = base.components(separatedBy: "/")
|
|
|
|
var currentURL = documentsPath
|
|
for component in components {
|
|
currentURL = currentURL.appendingPathComponent(component, isDirectory: true)
|
|
}
|
|
let modelScopePath = currentURL.appendingPathComponent(modelId, isDirectory: true)
|
|
|
|
try fileManager.createDirectoryIfNeeded(at: modelScopePath.path)
|
|
|
|
return modelScopePath.path
|
|
}
|
|
}
|