feat: add download recovery

This commit is contained in:
nimbold
2026-06-02 04:29:14 +03:30
parent a4740190fd
commit dd348c46da
3 changed files with 123 additions and 17 deletions
@@ -177,6 +177,10 @@ final class Aria2DownloadEngine {
"--download-result=hide", "--download-result=hide",
"--file-allocation=none", "--file-allocation=none",
"--min-split-size=1M", "--min-split-size=1M",
"--max-tries=10",
"--retry-wait=5",
"--connect-timeout=30",
"--timeout=60",
"--input-file=-" "--input-file=-"
] ]
+118 -17
View File
@@ -11,8 +11,11 @@ final class DownloadController: ObservableObject {
private let settings: AppSettings private let settings: AppSettings
private let engine = Aria2DownloadEngine() private let engine = Aria2DownloadEngine()
private var activeHandles: [UUID: Aria2DownloadEngine.Handle] = [:] private var activeHandles: [UUID: Aria2DownloadEngine.Handle] = [:]
private var automaticRetryCounts: [UUID: Int] = [:]
private var restrictQueueToAutoResume = false
private var sleepActivity: SleepActivityHandle? private var sleepActivity: SleepActivityHandle?
private var cancellables = Set<AnyCancellable>() private var cancellables = Set<AnyCancellable>()
private let maxAutomaticRetries = 3
private lazy var storageURL: URL = { private lazy var storageURL: URL = {
let supportDir = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first ?? URL(fileURLWithPath: NSHomeDirectory()) let supportDir = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first ?? URL(fileURLWithPath: NSHomeDirectory())
return supportDir.appendingPathComponent("Firelink").appendingPathComponent("downloads.json") return supportDir.appendingPathComponent("Firelink").appendingPathComponent("downloads.json")
@@ -20,9 +23,9 @@ final class DownloadController: ObservableObject {
init(settings: AppSettings) { init(settings: AppSettings) {
self.settings = settings self.settings = settings
loadDownloads() let shouldResumeRecoveredDownloads = loadDownloads()
settings.$preventsSleepWhileDownloading settings.$preventsSleepWhileDownloading
.sink { [weak self] _ in .sink { [weak self] _ in
Task { @MainActor in Task { @MainActor in
@@ -44,6 +47,14 @@ final class DownloadController: ObservableObject {
self?.saveDownloads() self?.saveDownloads()
} }
.store(in: &cancellables) .store(in: &cancellables)
if shouldResumeRecoveredDownloads {
Task { @MainActor in
self.engineMessage = "Recovered downloads from the previous session."
self.restrictQueueToAutoResume = true
self.pumpQueue()
}
}
} }
deinit { deinit {
@@ -91,6 +102,7 @@ final class DownloadController: ObservableObject {
downloads.append(item) downloads.append(item)
engineMessage = "Added \(fileName) to \(category.rawValue)." engineMessage = "Added \(fileName) to \(category.rawValue)."
saveDownloads()
} }
func addPendingDownloads( func addPendingDownloads(
@@ -117,6 +129,7 @@ final class DownloadController: ObservableObject {
downloads.append(contentsOf: items) downloads.append(contentsOf: items)
engineMessage = "Added \(items.count) download\(items.count == 1 ? "" : "s")." engineMessage = "Added \(items.count) download\(items.count == 1 ? "" : "s")."
saveDownloads()
if startImmediately { if startImmediately {
startQueue() startQueue()
@@ -125,6 +138,8 @@ final class DownloadController: ObservableObject {
func startQueue() { func startQueue() {
engineMessage = "" engineMessage = ""
restrictQueueToAutoResume = false
markQueuedDownloadsForAutoResume()
pumpQueue() pumpQueue()
} }
@@ -134,7 +149,10 @@ final class DownloadController: ObservableObject {
update(item.id) { update(item.id) {
$0.status = .paused $0.status = .paused
$0.message = "Paused. Resume will continue from the partial file." $0.message = "Paused. Resume will continue from the partial file."
$0.autoResumeOnLaunch = false
} }
automaticRetryCounts[item.id] = nil
saveDownloads()
updateSleepActivity() updateSleepActivity()
pumpQueue() pumpQueue()
} }
@@ -151,15 +169,22 @@ final class DownloadController: ObservableObject {
$0.connectionCount = 0 $0.connectionCount = 0
} }
$0.message = "Added to queue" $0.message = "Added to queue"
$0.autoResumeOnLaunch = false
} }
automaticRetryCounts[item.id] = nil
saveDownloads()
updateSleepActivity() updateSleepActivity()
} }
func resume(_ item: DownloadItem) { func resume(_ item: DownloadItem) {
restrictQueueToAutoResume = false
update(item.id) { update(item.id) {
$0.status = .queued $0.status = .queued
$0.message = "" $0.message = ""
$0.autoResumeOnLaunch = true
} }
automaticRetryCounts[item.id] = nil
saveDownloads()
pumpQueue() pumpQueue()
} }
@@ -169,7 +194,10 @@ final class DownloadController: ObservableObject {
update(item.id) { update(item.id) {
$0.status = .canceled $0.status = .canceled
$0.message = "Canceled" $0.message = "Canceled"
$0.autoResumeOnLaunch = false
} }
automaticRetryCounts[item.id] = nil
saveDownloads()
updateSleepActivity() updateSleepActivity()
pumpQueue() pumpQueue()
} }
@@ -190,11 +218,14 @@ final class DownloadController: ObservableObject {
removeCacheFiles(for: item) removeCacheFiles(for: item)
} }
downloads.removeAll { $0.id == item.id } downloads.removeAll { $0.id == item.id }
automaticRetryCounts[item.id] = nil
saveDownloads()
updateSleepActivity() updateSleepActivity()
} }
func move(from source: IndexSet, to destination: Int) { func move(from source: IndexSet, to destination: Int) {
downloads.move(fromOffsets: source, toOffset: destination) downloads.move(fromOffsets: source, toOffset: destination)
saveDownloads()
} }
private func pumpQueue() { private func pumpQueue() {
@@ -204,9 +235,17 @@ final class DownloadController: ObservableObject {
} }
while activeCount < settings.maxConcurrentDownloads, while activeCount < settings.maxConcurrentDownloads,
let next = downloads.first(where: { $0.status == .queued }) { let next = downloads.first(where: { item in
item.status == .queued && (!restrictQueueToAutoResume || item.autoResumeOnLaunch == true)
}) {
start(next) start(next)
} }
if restrictQueueToAutoResume &&
activeCount == 0 &&
!downloads.contains(where: { $0.status == .queued && $0.autoResumeOnLaunch == true }) {
restrictQueueToAutoResume = false
}
} }
private func start(_ item: DownloadItem) { private func start(_ item: DownloadItem) {
@@ -216,7 +255,9 @@ final class DownloadController: ObservableObject {
$0.message = "Starting" $0.message = "Starting"
$0.speedText = "-" $0.speedText = "-"
$0.etaText = "-" $0.etaText = "-"
$0.autoResumeOnLaunch = true
} }
saveDownloads()
do { do {
let handle = try engine.start( let handle = try engine.start(
@@ -241,22 +282,22 @@ final class DownloadController: ObservableObject {
switch result { switch result {
case .success: case .success:
self.automaticRetryCounts[item.id] = nil
self.update(item.id) { self.update(item.id) {
$0.status = .completed $0.status = .completed
$0.progress = 1 $0.progress = 1
$0.speedText = "-" $0.speedText = "-"
$0.etaText = "-" $0.etaText = "-"
$0.message = "Saved to \($0.destinationPath)" $0.message = "Saved to \($0.destinationPath)"
$0.autoResumeOnLaunch = false
} }
self.saveDownloads()
case .failure(let error): case .failure(let error):
if self.downloads.first(where: { $0.id == item.id })?.status == .paused || if self.downloads.first(where: { $0.id == item.id })?.status == .paused ||
self.downloads.first(where: { $0.id == item.id })?.status == .canceled { self.downloads.first(where: { $0.id == item.id })?.status == .canceled {
return return
} }
self.update(item.id) { self.handleDownloadFailure(itemID: item.id, error: error)
$0.status = .failed
$0.message = error.localizedDescription
}
} }
self.pumpQueue() self.pumpQueue()
@@ -268,12 +309,10 @@ final class DownloadController: ObservableObject {
update(item.id) { update(item.id) {
$0.message = "Process \(handle.processIdentifier)" $0.message = "Process \(handle.processIdentifier)"
} }
saveDownloads()
updateSleepActivity() updateSleepActivity()
} catch { } catch {
update(item.id) { handleDownloadFailure(itemID: item.id, error: error)
$0.status = .failed
$0.message = error.localizedDescription
}
updateSleepActivity() updateSleepActivity()
pumpQueue() pumpQueue()
} }
@@ -301,6 +340,56 @@ final class DownloadController: ObservableObject {
$0.credentials = credentials $0.credentials = credentials
$0.message = "Properties updated" $0.message = "Properties updated"
} }
saveDownloads()
}
private func markQueuedDownloadsForAutoResume() {
for index in downloads.indices where downloads[index].status == .queued {
downloads[index].autoResumeOnLaunch = true
}
saveDownloads()
}
private func handleDownloadFailure(itemID: UUID, error: Error) {
let retryCount = automaticRetryCounts[itemID] ?? 0
guard isAutomaticallyRecoverable(error), retryCount < maxAutomaticRetries else {
automaticRetryCounts[itemID] = nil
update(itemID) {
$0.status = .failed
$0.speedText = "-"
$0.etaText = "-"
$0.connectionCount = 0
$0.message = error.localizedDescription
$0.autoResumeOnLaunch = false
}
saveDownloads()
return
}
automaticRetryCounts[itemID] = retryCount + 1
update(itemID) {
$0.status = .queued
$0.speedText = "-"
$0.etaText = "-"
$0.connectionCount = 0
$0.message = "Connection interrupted. Retrying from partial file (\(retryCount + 1)/\(maxAutomaticRetries))."
$0.autoResumeOnLaunch = true
}
saveDownloads()
}
private func isAutomaticallyRecoverable(_ error: Error) -> Bool {
guard let engineError = error as? Aria2DownloadEngine.EngineError else {
return true
}
switch engineError {
case .executableNotFound, .unsupportedProxy:
return false
case .launchFailed:
return true
}
} }
private func updateSleepActivity() { private func updateSleepActivity() {
@@ -349,25 +438,37 @@ final class DownloadController: ObservableObject {
} }
} }
private func loadDownloads() { private func loadDownloads() -> Bool {
do { do {
guard FileManager.default.fileExists(atPath: storageURL.path) else { return } guard FileManager.default.fileExists(atPath: storageURL.path) else { return false }
let data = try Data(contentsOf: storageURL) let data = try Data(contentsOf: storageURL)
let loaded = try JSONDecoder().decode([DownloadItem].self, from: data) let loaded = try JSONDecoder().decode([DownloadItem].self, from: data)
var shouldResumeRecoveredDownloads = false
self.downloads = loaded.map { item in self.downloads = loaded.map { item in
var adjusted = item var adjusted = item
if adjusted.status == .downloading { if adjusted.status == .downloading {
adjusted.status = .paused adjusted.status = .queued
adjusted.message = "Paused on startup" adjusted.message = "Recovered after restart. Resuming from partial file."
adjusted.speedText = "-" adjusted.speedText = "-"
adjusted.etaText = "-" adjusted.etaText = "-"
adjusted.connectionCount = 0 adjusted.connectionCount = 0
adjusted.autoResumeOnLaunch = true
shouldResumeRecoveredDownloads = true
} else if adjusted.status == .queued && adjusted.autoResumeOnLaunch == true {
adjusted.message = "Recovered queued download."
shouldResumeRecoveredDownloads = true
} }
return adjusted return adjusted
} }
if shouldResumeRecoveredDownloads {
saveDownloads()
}
return shouldResumeRecoveredDownloads
} catch { } catch {
print("Failed to load downloads: \(error)") print("Failed to load downloads: \(error)")
return false
} }
} }
} }
+1
View File
@@ -57,6 +57,7 @@ struct DownloadItem: Identifiable, Codable, Equatable, Sendable {
var message: String = "" var message: String = ""
var createdAt = Date() var createdAt = Date()
var lastTryAt: Date? var lastTryAt: Date?
var autoResumeOnLaunch: Bool?
var destinationPath: String { var destinationPath: String {
destinationDirectory.appendingPathComponent(fileName).path destinationDirectory.appendingPathComponent(fileName).path