Download System - MKS2508/MKS-IPTV-App GitHub Wiki

<style> body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; line-height: 1.6; color: #333; max-width: 960px; margin: 0 auto; padding: 20px; } h1, h2, h3, h4, h5, h6 { font-weight: 600; color: #111; } a { color: #0366d6; text-decoration: none; } a:hover { text-decoration: underline; } code { font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, Courier, monospace; background-color: #f6f8fa; padding: .2em .4em; margin: 0; font-size: 85%; border-radius: 3px; } pre { background-color: #f6f8fa; padding: 16px; overflow: auto; line-height: 1.45; border-radius: 3px; } pre code { padding: 0; margin: 0; font-size: 100%; background-color: transparent; border: 0; } footer { margin-top: 40px; padding-top: 20px; border-top: 1px solid #eee; font-size: 12px; color: #586069; } </style>

Home > Architecture > Download System

πŸ“₯ Download System

Comprehensive guide to MKS-IPTV-App's advanced download management system with HTTP server integration.


🎯 System Overview

The Download System is a sophisticated multi-component architecture that handles:

  • Concurrent downloads with progress tracking
  • Local HTTP server for streaming downloaded content
  • MOV conversion for optimal Apple ecosystem integration
  • Queue management with priority scheduling
  • Resume/pause functionality with state persistence
  • Background downloads (iOS/macOS)

πŸ—οΈ Core Architecture

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚                    Download System                          β”‚
β”‚                                                             β”‚
β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚
β”‚  β”‚ DownloadManager β”‚  β”‚ HTTPStreamServerβ”‚  β”‚ ConversionEngineβ”‚ β”‚
β”‚  β”‚    (Actor)      β”‚  β”‚    (Actor)      β”‚  β”‚    (Actor)      β”‚ β”‚
β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚
β”‚           β”‚                      β”‚                      β”‚     β”‚
β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚
β”‚  β”‚  QueueManager   β”‚  β”‚  FileManager    β”‚  β”‚ ProgressTracker β”‚ β”‚
β”‚  β”‚    (Actor)      β”‚  β”‚    (Actor)      β”‚  β”‚    (Actor)      β”‚ β”‚
β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

🎬 DownloadManager Implementation

Core Actor Structure

actor DownloadManager: ObservableObject {
    // MARK: - State
    private var activeDownloads: [UUID: DownloadTask] = [:]
    private var downloadQueue: [UUID] = []
    private var completedDownloads: Set<UUID> = []
    
    // MARK: - Configuration
    private let maxConcurrentDownloads = 3
    private let downloadDirectory: URL
    private let conversionEngine: ConversionEngine
    private let progressTracker: ProgressTracker
    
    // MARK: - URLSession
    private lazy var downloadSession: URLSession = {
        let config = URLSessionConfiguration.background(
            withIdentifier: "com.mks.iptv.downloads"
        )
        config.isDiscretionary = false
        config.sessionSendsLaunchEvents = true
        return URLSession(
            configuration: config,
            delegate: self,
            delegateQueue: nil
        )
    }()
    
    init() {
        self.downloadDirectory = FileManager.default.downloadsDirectory
        self.conversionEngine = ConversionEngine()
        self.progressTracker = ProgressTracker()
        
        // Restore previous downloads on startup
        Task {
            await restorePreviousDownloads()
        }
    }
}

Download Task Management

struct DownloadTask: Codable, Identifiable, Sendable {
    let id: UUID
    let originalURL: URL
    let localPath: URL
    let title: String
    let thumbnailURL: URL?
    let totalBytes: Int64
    
    var urlSessionTask: URLSessionDownloadTask?
    var status: DownloadStatus
    var bytesDownloaded: Int64
    var startTime: Date
    var estimatedTimeRemaining: TimeInterval?
    var downloadSpeed: Double
    var shouldConvertToMOV: Bool
    
    // Computed properties
    var progress: Double {
        guard totalBytes > 0 else { return 0 }
        return Double(bytesDownloaded) / Double(totalBytes)
    }
    
    var isActive: Bool {
        [.downloading, .converting].contains(status)
    }
    
    var formattedSpeed: String {
        let formatter = ByteCountFormatter()
        return "\(formatter.string(fromByteCount: Int64(downloadSpeed)))/s"
    }
}

Download Initiation

func startDownload(for item: DownloadableItem) async throws -> UUID {
    // 1. Validate input
    guard item.downloadURL.isValidDownloadURL else {
        throw DownloadError.invalidURL(item.downloadURL)
    }
    
    // 2. Check available storage
    let requiredSpace = item.estimatedSize ?? 0
    try await checkAvailableStorage(requiredBytes: requiredSpace)
    
    // 3. Create download task
    let downloadId = UUID()
    let localPath = downloadDirectory
        .appendingPathComponent(downloadId.uuidString)
        .appendingPathExtension("tmp")
    
    let task = DownloadTask(
        id: downloadId,
        originalURL: item.downloadURL,
        localPath: localPath,
        title: item.title,
        thumbnailURL: item.thumbnailURL,
        totalBytes: requiredSpace,
        status: .pending,
        bytesDownloaded: 0,
        startTime: Date(),
        downloadSpeed: 0,
        shouldConvertToMOV: UserDefaults.standard.bool(forKey: "autoConvertToMOV")
    )
    
    // 4. Add to queue
    activeDownloads[downloadId] = task
    downloadQueue.append(downloadId)
    
    // 5. Start download if under concurrent limit
    await processDownloadQueue()
    
    // 6. Notify observers
    await MainActor.run {
        objectWillChange.send()
    }
    
    logger.info("Download queued", metadata: [
        "downloadId": downloadId.uuidString,
        "title": item.title,
        "url": item.downloadURL.absoluteString
    ])
    
    return downloadId
}

Queue Processing

private func processDownloadQueue() async {
    let activeCount = activeDownloads.values.filter { $0.isActive }.count
    let availableSlots = maxConcurrentDownloads - activeCount
    
    guard availableSlots > 0 else { return }
    
    let pendingDownloads = downloadQueue.prefix(availableSlots)
    
    for downloadId in pendingDownloads {
        guard var task = activeDownloads[downloadId],
              task.status == .pending else { continue }
        
        do {
            // Create URLSessionDownloadTask
            let urlTask = downloadSession.downloadTask(with: task.originalURL)
            
            // Update task
            task.urlSessionTask = urlTask
            task.status = .downloading
            task.startTime = Date()
            activeDownloads[downloadId] = task
            
            // Start download
            urlTask.resume()
            
            // Remove from queue
            downloadQueue.removeAll { $0 == downloadId }
            
            logger.info("Download started", metadata: [
                "downloadId": downloadId.uuidString
            ])
            
        } catch {
            task.status = .failed
            activeDownloads[downloadId] = task
            
            logger.error("Failed to start download", metadata: [
                "downloadId": downloadId.uuidString,
                "error": error.localizedDescription
            ])
        }
    }
}

πŸ“Š Progress Tracking

ProgressTracker Actor

actor ProgressTracker {
    private var progressUpdates: [UUID: ProgressUpdate] = [:]
    private var speedCalculator = SpeedCalculator()
    
    struct ProgressUpdate {
        let bytesDownloaded: Int64
        let totalBytes: Int64
        let timestamp: Date
        
        var progress: Double {
            guard totalBytes > 0 else { return 0 }
            return Double(bytesDownloaded) / Double(totalBytes)
        }
    }
    
    func updateProgress(
        for downloadId: UUID,
        bytesDownloaded: Int64,
        totalBytes: Int64
    ) async {
        let update = ProgressUpdate(
            bytesDownloaded: bytesDownloaded,
            totalBytes: totalBytes,
            timestamp: Date()
        )
        
        progressUpdates[downloadId] = update
        
        // Calculate download speed
        let speed = await speedCalculator.calculateSpeed(
            for: downloadId,
            bytesDownloaded: bytesDownloaded
        )
        
        // Estimate time remaining
        let remainingBytes = totalBytes - bytesDownloaded
        let estimatedTime = speed > 0 ? Double(remainingBytes) / speed : nil
        
        // Update download task
        await updateDownloadMetrics(
            downloadId: downloadId,
            speed: speed,
            estimatedTime: estimatedTime
        )
    }
}

private actor SpeedCalculator {
    private var samples: [UUID: [SpeedSample]] = [:]
    private let maxSamples = 10
    
    struct SpeedSample {
        let bytes: Int64
        let timestamp: Date
    }
    
    func calculateSpeed(for downloadId: UUID, bytesDownloaded: Int64) -> Double {
        let now = Date()
        let sample = SpeedSample(bytes: bytesDownloaded, timestamp: now)
        
        // Add sample
        if samples[downloadId] == nil {
            samples[downloadId] = []
        }
        samples[downloadId]!.append(sample)
        
        // Keep only recent samples
        samples[downloadId] = samples[downloadId]!.suffix(maxSamples)
        
        // Calculate speed from samples
        guard let downloadSamples = samples[downloadId],
              downloadSamples.count >= 2 else {
            return 0
        }
        
        let oldest = downloadSamples.first!
        let newest = downloadSamples.last!
        
        let timeDiff = newest.timestamp.timeIntervalSince(oldest.timestamp)
        guard timeDiff > 0 else { return 0 }
        
        let bytesDiff = newest.bytes - oldest.bytes
        return Double(bytesDiff) / timeDiff
    }
}

🌐 HTTP Stream Server

Server Implementation

actor HTTPStreamServer {
    private var listener: NWListener?
    private var activeConnections: Set<NWConnection> = []
    private let port: Int
    private let fileManager: FileManager
    
    init(port: Int = 8080) {
        self.port = port
        self.fileManager = FileManager.default
    }
    
    func startServer() async throws {
        let parameters = NWParameters.tcp
        parameters.allowLocalEndpointReuse = true
        
        listener = try NWListener(using: parameters, on: NWEndpoint.Port(integerLiteral: UInt16(port)))
        
        listener?.newConnectionHandler = { [weak self] connection in
            Task {
                await self?.handleConnection(connection)
            }
        }
        
        listener?.start(queue: .global())
        
        logger.info("HTTP server started on port \(port)")
    }
    
    private func handleConnection(_ connection: NWConnection) async {
        activeConnections.insert(connection)
        connection.start(queue: .global())
        
        do {
            while true {
                let request = try await receiveHTTPRequest(connection)
                try await processRequest(request, connection: connection)
            }
        } catch {
            logger.debug("Connection closed: \(error)")
        }
        
        activeConnections.remove(connection)
        connection.cancel()
    }
}

File Serving

private func processRequest(_ request: HTTPRequest, connection: NWConnection) async throws {
    // Parse request path
    guard let url = URL(string: request.path),
          let downloadId = UUID(uuidString: url.lastPathComponent) else {
        try await sendErrorResponse(connection, status: 404)
        return
    }
    
    // Find download file
    guard let filePath = await getDownloadPath(for: downloadId) else {
        try await sendErrorResponse(connection, status: 404)
        return
    }
    
    // Check if file exists
    guard fileManager.fileExists(atPath: filePath.path) else {
        try await sendErrorResponse(connection, status: 404)
        return
    }
    
    // Handle range requests for video streaming
    if let rangeHeader = request.headers["Range"] {
        try await sendRangeResponse(
            connection,
            filePath: filePath,
            range: rangeHeader
        )
    } else {
        try await sendFullFileResponse(connection, filePath: filePath)
    }
}

private func sendRangeResponse(
    _ connection: NWConnection,
    filePath: URL,
    range: String
) async throws {
    // Parse range header (e.g., "bytes=0-1023")
    let rangePattern = /bytes=(\d+)-(\d*)/
    guard let match = range.firstMatch(of: rangePattern) else {
        try await sendErrorResponse(connection, status: 416)
        return
    }
    
    let startByte = Int64(match.1)!
    let fileSize = try fileManager.attributesOfItem(atPath: filePath.path)[.size] as! Int64
    let endByte = match.2.isEmpty ? fileSize - 1 : min(Int64(match.2)!, fileSize - 1)
    
    // Read file chunk
    let fileHandle = try FileHandle(forReadingFrom: filePath)
    defer { try? fileHandle.close() }
    
    try fileHandle.seek(toOffset: UInt64(startByte))
    let chunkSize = Int(endByte - startByte + 1)
    let data = fileHandle.readData(ofLength: chunkSize)
    
    // Send response headers
    let headers = [
        "HTTP/1.1 206 Partial Content",
        "Content-Range: bytes \(startByte)-\(endByte)/\(fileSize)",
        "Content-Length: \(data.count)",
        "Content-Type: video/mp4",
        "Accept-Ranges: bytes",
        "Access-Control-Allow-Origin: *",
        "",
        ""
    ].joined(separator: "\r\n")
    
    try await connection.send(content: headers.data(using: .utf8))
    try await connection.send(content: data)
}

πŸ”„ MOV Conversion System

ConversionEngine Actor

actor ConversionEngine {
    private var activeConversions: [UUID: ConversionTask] = [:]
    private let maxConcurrentConversions = 2
    
    struct ConversionTask {
        let id: UUID
        let inputPath: URL
        let outputPath: URL
        let progress: Double
        let startTime: Date
    }
    
    func convertToMOV(_ downloadId: UUID, inputPath: URL) async throws -> URL {
        let outputPath = inputPath
            .deletingPathExtension()
            .appendingPathExtension("mov")
        
        let conversionId = UUID()
        let task = ConversionTask(
            id: conversionId,
            inputPath: inputPath,
            outputPath: outputPath,
            progress: 0,
            startTime: Date()
        )
        
        activeConversions[conversionId] = task
        
        defer {
            activeConversions.removeValue(forKey: conversionId)
        }
        
        try await performConversion(from: inputPath, to: outputPath) { progress in
            Task {
                await self.updateConversionProgress(conversionId, progress: progress)
            }
        }
        
        // Enhance metadata for Apple ecosystem
        try await enhanceMetadata(at: outputPath)
        
        return outputPath
    }
    
    private func performConversion(
        from inputPath: URL,
        to outputPath: URL,
        progressHandler: @escaping (Double) -> Void
    ) async throws {
        let asset = AVAsset(url: inputPath)
        
        guard let exportSession = AVAssetExportSession(
            asset: asset,
            presetName: AVAssetExportPresetHighestQuality
        ) else {
            throw ConversionError.exportSessionCreationFailed
        }
        
        exportSession.outputURL = outputPath
        exportSession.outputFileType = .mov
        exportSession.shouldOptimizeForNetworkUse = true
        
        // Configure for Apple ecosystem optimization
        exportSession.metadata = await createOptimizedMetadata()
        
        // Start conversion
        await exportSession.export()
        
        // Check for errors
        if let error = exportSession.error {
            throw ConversionError.exportFailed(error)
        }
        
        guard exportSession.status == .completed else {
            throw ConversionError.exportIncomplete
        }
    }
    
    private func enhanceMetadata(at path: URL) async throws {
        let asset = AVMutableAsset(url: path)
        
        // Add Apple-specific metadata
        let metadata = [
            AVMutableMetadataItem.makeItem(
                identifier: .commonIdentifierCreationDate,
                value: Date() as NSDate
            ),
            AVMutableMetadataItem.makeItem(
                identifier: .commonIdentifierSoftware,
                value: "MKS-IPTV-App" as NSString
            ),
            AVMutableMetadataItem.makeItem(
                identifier: .quickTimeUserDataCompatibleBrands,
                value: "mp42,isom" as NSString
            )
        ]
        
        asset.metadata = metadata
        
        // Export with enhanced metadata
        guard let exportSession = AVAssetExportSession(
            asset: asset,
            presetName: AVAssetExportPresetPassthrough
        ) else {
            throw ConversionError.metadataEnhancementFailed
        }
        
        let tempURL = path.appendingPathExtension("tmp")
        exportSession.outputURL = tempURL
        exportSession.outputFileType = .mov
        
        await exportSession.export()
        
        // Replace original file
        _ = try FileManager.default.replaceItem(
            at: path,
            withItemAt: tempURL,
            backupItemName: nil,
            options: [],
            resultingItemURL: nil
        )
    }
}

πŸ’Ύ Persistence & Recovery

Download State Persistence

extension DownloadManager {
    private func saveDownloadState() async {
        let encoder = JSONEncoder()
        let downloadStates = activeDownloads.values.map { task in
            DownloadState(
                id: task.id,
                originalURL: task.originalURL,
                localPath: task.localPath,
                title: task.title,
                status: task.status,
                bytesDownloaded: task.bytesDownloaded,
                totalBytes: task.totalBytes,
                shouldConvertToMOV: task.shouldConvertToMOV
            )
        }
        
        do {
            let data = try encoder.encode(downloadStates)
            let stateURL = downloadDirectory.appendingPathComponent("download_state.json")
            try data.write(to: stateURL)
        } catch {
            logger.error("Failed to save download state: \(error)")
        }
    }
    
    private func restorePreviousDownloads() async {
        let stateURL = downloadDirectory.appendingPathComponent("download_state.json")
        
        guard FileManager.default.fileExists(atPath: stateURL.path) else {
            return
        }
        
        do {
            let data = try Data(contentsOf: stateURL)
            let decoder = JSONDecoder()
            let downloadStates = try decoder.decode([DownloadState].self, from: data)
            
            for state in downloadStates {
                // Restore interrupted downloads
                if state.status == .downloading {
                    await resumeDownload(state.id)
                }
            }
            
            logger.info("Restored \(downloadStates.count) previous downloads")
            
        } catch {
            logger.error("Failed to restore downloads: \(error)")
        }
    }
}

πŸ§ͺ Testing & Debugging

Mock Download Manager

#if DEBUG
actor MockDownloadManager: DownloadManagerProtocol {
    var mockDownloads: [UUID: DownloadTask] = [:]
    var simulateFailure = false
    var downloadSpeed: Double = 1_000_000 // 1 MB/s
    
    func startDownload(for item: DownloadableItem) async throws -> UUID {
        if simulateFailure {
            throw DownloadError.networkError(URLError(.networkConnectionLost))
        }
        
        let downloadId = UUID()
        let task = DownloadTask(
            id: downloadId,
            originalURL: item.downloadURL,
            localPath: URL(fileURLWithPath: "/tmp/\(downloadId)"),
            title: item.title,
            thumbnailURL: item.thumbnailURL,
            totalBytes: item.estimatedSize ?? 100_000_000,
            status: .downloading,
            bytesDownloaded: 0,
            startTime: Date(),
            downloadSpeed: downloadSpeed,
            shouldConvertToMOV: false
        )
        
        mockDownloads[downloadId] = task
        
        // Simulate download progress
        Task {
            await simulateDownloadProgress(downloadId)
        }
        
        return downloadId
    }
    
    private func simulateDownloadProgress(_ downloadId: UUID) async {
        guard var task = mockDownloads[downloadId] else { return }
        
        while task.bytesDownloaded < task.totalBytes {
            task.bytesDownloaded += Int64(downloadSpeed / 10) // Update every 100ms
            task.downloadSpeed = downloadSpeed
            mockDownloads[downloadId] = task
            
            try? await Task.sleep(nanoseconds: 100_000_000) // 100ms
        }
        
        task.status = .completed
        mockDownloads[downloadId] = task
    }
}
#endif

The Download System provides a comprehensive solution for managing large file downloads with streaming capabilities, conversion options, and robust error recovery mechanisms.


{{< include _Footer.md >}}

⚠️ **GitHub.com Fallback** ⚠️