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
Comprehensive guide to MKS-IPTV-App's advanced download management system with HTTP server integration.
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)
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Download System β
β β
β βββββββββββββββββββ βββββββββββββββββββ βββββββββββββββββββ β
β β DownloadManager β β HTTPStreamServerβ β ConversionEngineβ β
β β (Actor) β β (Actor) β β (Actor) β β
β βββββββββββββββββββ βββββββββββββββββββ βββββββββββββββββββ β
β β β β β
β βββββββββββββββββββ βββββββββββββββββββ βββββββββββββββββββ β
β β QueueManager β β FileManager β β ProgressTracker β β
β β (Actor) β β (Actor) β β (Actor) β β
β βββββββββββββββββββ βββββββββββββββββββ βββββββββββββββββββ β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
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()
}
}
}
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"
}
}
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
}
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
])
}
}
}
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
}
}
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()
}
}
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)
}
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
)
}
}
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)")
}
}
}
#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 >}}