Surface the existing pin (keep-from-cull) and per-file delete actions as visible inline buttons on each offline cache row instead of context-menu-only: a star toggles protection from auto-cull (and restore-if-missing), a trash culls that file early. Aligns wording/icons to the star metaphor. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
299 lines
14 KiB
Swift
299 lines
14 KiB
Swift
import Foundation
|
|
import Observation
|
|
|
|
/// Drives the Downloads tab: the live transmission transfer dashboard (polled).
|
|
/// Search lives in its own Search tab now. @Observable/@MainActor like the others.
|
|
@Observable
|
|
@MainActor
|
|
public final class DownloadsController {
|
|
public private(set) var transfers: [TorrentRow] = []
|
|
public private(set) var transfersError: String?
|
|
public private(set) var lastAction: String?
|
|
/// Set true while the Downloads tab is on screen — drives the poll cadence so
|
|
/// we don't hammer black (each poll is a cold SSH + torrent-get over a loaded
|
|
/// disk) while the user is elsewhere. Polling never fully stops, so the tab
|
|
/// opens on last-known data instead of a blank cold fetch.
|
|
public var detailVisible = false { didSet { if detailVisible != oldValue { wake() } } }
|
|
/// Fired when transfers transition to complete (or on startup backfill), with
|
|
/// the black-side content folders — the owner scans them directly into the library.
|
|
public var onDownloadsCompleted: (([String]) -> Void)?
|
|
|
|
private let service: TorrentService
|
|
private var pollTask: Task<Void, Never>?
|
|
private var polling = false
|
|
private var completedIds: Set<Int> = []
|
|
/// When each downloading torrent's rate first hit 0 — to time the stall/dead
|
|
/// thresholds in `TransferHealth` across polls.
|
|
private var stuckSince: [Int: Date] = [:]
|
|
/// Ids already alerted as needing attention, so we notify once per entry.
|
|
private var notifiedAttention: Set<Int> = []
|
|
|
|
public init(service: TorrentService = TorrentService()) { self.service = service }
|
|
|
|
/// Exposed for DownloadsView re-search flows (title extraction + search).
|
|
public func researchQuery(for row: TorrentRow) -> String { service.researchQuery(for: row) }
|
|
public func searchForResearch(_ q: String, limit: Int = 8) async throws -> [TorrentResult] {
|
|
try await service.searchForResearch(q, limit: limit)
|
|
}
|
|
|
|
public func warmupSearch() async { await service.warmupSearch() }
|
|
|
|
public func start() {
|
|
pollTask?.cancel()
|
|
pollTask = Task { [weak self] in
|
|
while !Task.isCancelled {
|
|
await self?.refreshTransfers()
|
|
let secs = self?.pollInterval ?? 60
|
|
try? await Task.sleep(for: .seconds(secs))
|
|
}
|
|
}
|
|
}
|
|
|
|
public func stop() { pollTask?.cancel() }
|
|
|
|
/// Restart the loop so a visibility change takes effect immediately (an
|
|
/// in-flight `Task.sleep` would otherwise hold the old, slower cadence).
|
|
private func wake() { if pollTask != nil { start() } }
|
|
|
|
/// Poll fast only when it matters — the tab is open AND something is actually
|
|
/// downloading. Everything idle/seeding + tab hidden backs right off.
|
|
private var pollInterval: Double {
|
|
let downloading = transfers.contains { !$0.isComplete }
|
|
switch (detailVisible, downloading) {
|
|
case (true, true): return 4
|
|
case (true, false): return 12
|
|
case (false, true): return 20
|
|
case (false, false): return 60
|
|
}
|
|
}
|
|
|
|
// MARK: dashboard facets (each transfer falls in exactly one)
|
|
|
|
/// Still fetching (not yet 100%).
|
|
public var downloading: [TorrentRow] { transfers.filter { !$0.isComplete } }
|
|
/// Complete and still seeding.
|
|
public var seeding: [TorrentRow] { transfers.filter { $0.isComplete && $0.status == 6 } }
|
|
/// Complete, not seeding (done), newest first.
|
|
public var completed: [TorrentRow] { transfers.filter { $0.isComplete && $0.status != 6 } }
|
|
|
|
public var totalDownRate: Int { transfers.reduce(0) { $0 + $1.rateDownload } }
|
|
public var totalUpRate: Int { transfers.reduce(0) { $0 + $1.rateUpload } }
|
|
|
|
public static func rate(_ bytesPerSec: Int) -> String {
|
|
guard bytesPerSec > 0 else { return "—" }
|
|
let f = ByteCountFormatter(); f.countStyle = .file
|
|
return f.string(fromByteCount: Int64(bytesPerSec)) + "/s"
|
|
}
|
|
|
|
/// The single most-recently-completed transfer, surfaced at the top of the UI.
|
|
public var mostRecentlyCompleted: TorrentRow? {
|
|
transfers.first { $0.completedAt != nil }
|
|
}
|
|
|
|
/// Most-recently-completed first (doneDate desc); still-downloading
|
|
/// (doneDate 0) sink to the bottom, newest-added first among them.
|
|
static func byRecency(_ a: TorrentRow, _ b: TorrentRow) -> Bool {
|
|
if a.doneDate != b.doneDate { return a.doneDate > b.doneDate }
|
|
return a.addedDate > b.addedDate
|
|
}
|
|
|
|
// MARK: health-aware ordering
|
|
|
|
/// Health of a transfer, timing the stall/dead thresholds from `stuckSince`.
|
|
public func health(_ row: TorrentRow) -> TransferHealth {
|
|
let stuck = stuckSince[row.id].map { Date().timeIntervalSince($0) }
|
|
return row.health(secondsStuck: stuck)
|
|
}
|
|
|
|
/// Active downloads, **attention first** (errored/dead/stalled), then by soonest
|
|
/// ETA — so what needs you, and what's about to be watchable, float to the top.
|
|
public var downloadingSorted: [TorrentRow] {
|
|
let ranked = downloading.map { ($0, health($0)) }
|
|
return ranked.sorted { Self.downloadOrder($0.0, $0.1, $1.0, $1.1) }.map(\.0)
|
|
}
|
|
|
|
/// How many active downloads need attention (stalled/dead/errored).
|
|
public var attentionCount: Int { downloading.lazy.filter { self.health($0).needsAttention }.count }
|
|
|
|
/// Pure ordering (unit-tested): by health priority, then soonest ETA.
|
|
static func downloadOrder(_ a: TorrentRow, _ ha: TransferHealth,
|
|
_ b: TorrentRow, _ hb: TransferHealth) -> Bool {
|
|
if ha.sortRank != hb.sortRank { return ha.sortRank < hb.sortRank }
|
|
let ea = a.eta > 0 ? a.eta : Int.max
|
|
let eb = b.eta > 0 ? b.eta : Int.max
|
|
return ea < eb
|
|
}
|
|
|
|
/// Update the rate-zero timers each poll: start the clock when a downloading
|
|
/// torrent's rate hits 0, clear it when bytes flow again or it's gone.
|
|
private func updateStuckTracking() {
|
|
let now = Date()
|
|
let present = Set(transfers.map(\.id))
|
|
for t in transfers where t.status == 4 {
|
|
if t.rateDownload == 0 { if stuckSince[t.id] == nil { stuckSince[t.id] = now } }
|
|
else { stuckSince[t.id] = nil }
|
|
}
|
|
stuckSince = stuckSince.filter { present.contains($0.key) }
|
|
}
|
|
|
|
public func refreshTransfers() async {
|
|
guard !polling else { return }
|
|
polling = true
|
|
defer { polling = false }
|
|
do {
|
|
// Most-recently-completed first (doneDate desc); still-downloading
|
|
// (doneDate 0) sink to the bottom, newest-added first among them.
|
|
transfers = try await service.list().sorted(by: Self.byRecency)
|
|
updateStuckTracking()
|
|
transfersError = nil
|
|
Log.info("refreshTransfers → \(transfers.count) transfers")
|
|
// A transfer flipping to complete = new media on black → rescan library.
|
|
// Notifications only fire for *new* completions; indexing also backfills
|
|
// any complete folder we've never kicked through --add (e.g. finished
|
|
// while the app was closed).
|
|
let nowComplete = Set(transfers.filter { $0.isComplete }.map(\.id))
|
|
var foldersToIndex: [String] = []
|
|
if !completedIds.isEmpty {
|
|
let newly = nowComplete.subtracting(completedIds)
|
|
if !newly.isEmpty {
|
|
let done = transfers.filter { newly.contains($0.id) }
|
|
Log.info("download complete (\(newly.count) item\(newly.count == 1 ? "" : "s")) → incremental index")
|
|
for t in done { NotificationsService.shared.post(title: "Ready to watch", body: t.name) }
|
|
foldersToIndex = done.compactMap(\.contentFolder)
|
|
}
|
|
} else {
|
|
let backfill = IndexedFoldersStore.unmarkedFolders(in: transfers)
|
|
if !backfill.isEmpty {
|
|
Log.info("index backfill (\(backfill.count) folder\(backfill.count == 1 ? "" : "s")) from startup baseline")
|
|
foldersToIndex = backfill
|
|
}
|
|
}
|
|
if !foldersToIndex.isEmpty {
|
|
IndexedFoldersStore.mark(foldersToIndex)
|
|
onDownloadsCompleted?(foldersToIndex)
|
|
}
|
|
completedIds = nowComplete
|
|
|
|
// Stall/dead surfacing — once per entry into an attention state.
|
|
let attentionNow = Set(downloading.filter { health($0).needsAttention }.map(\.id))
|
|
for id in attentionNow.subtracting(notifiedAttention) {
|
|
if let t = transfers.first(where: { $0.id == id }) {
|
|
NotificationsService.shared.post(title: "Download stuck", body: t.name)
|
|
}
|
|
}
|
|
notifiedAttention = attentionNow
|
|
} catch {
|
|
transfersError = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription
|
|
Log.error("refreshTransfers: \(transfersError ?? "?")")
|
|
}
|
|
}
|
|
|
|
/// Categories offered at add-time — route a torrent straight into media/<cat>.
|
|
public static let addCategories = ["tv", "anime", "movies", "cartoons", "unsorted", "porn"]
|
|
|
|
/// Warn when completed transfers in `category` are filed under the wrong path
|
|
/// (downloadDir doesn't live under `media/<category>/`).
|
|
public func misplaced(in category: String) -> String? {
|
|
let root = MediaPaths.remoteRoot + "/" + category
|
|
let bad = transfers.filter { row in
|
|
guard let dir = row.downloadDir else { return false }
|
|
return !dir.hasPrefix(root) && SearchMatcher.isMisplaced(row)
|
|
}
|
|
guard let first = bad.first else { return nil }
|
|
let extra = bad.count > 1 ? " (and \(bad.count - 1) more)" : ""
|
|
return "“\(first.name)” completed under \(first.downloadDir ?? "?"), not \(root). Move files on black before re-adding\(extra)."
|
|
}
|
|
|
|
public func add(_ result: TorrentResult, category: String? = nil) async {
|
|
guard let magnet = result.magnet else { return }
|
|
do {
|
|
try await service.add(magnet, category: category)
|
|
lastAction = category.map { "Added to \($0): \(result.filename)" } ?? "Added: \(result.filename)"
|
|
Log.info("add ok [\(category ?? "default")]: \(result.filename)")
|
|
await refreshTransfers()
|
|
} catch {
|
|
lastAction = "Add failed: \((error as? LocalizedError)?.errorDescription ?? error.localizedDescription)"
|
|
Log.error("add failed: \(result.filename): \(lastAction ?? "")")
|
|
}
|
|
}
|
|
|
|
public func remove(_ row: TorrentRow, deleteData: Bool) async {
|
|
do {
|
|
try await service.remove(row.id, deleteData: deleteData)
|
|
lastAction = "Removed: \(row.name)"
|
|
Log.info("remove ok (deleteData=\(deleteData)): \(row.name)")
|
|
await refreshTransfers()
|
|
} catch {
|
|
lastAction = "Remove failed: \((error as? LocalizedError)?.errorDescription ?? error.localizedDescription)"
|
|
Log.error("remove failed: \(row.name): \(lastAction ?? "")")
|
|
}
|
|
}
|
|
|
|
// MARK: per-torrent debug detail + advance actions
|
|
|
|
/// The transfer whose debug sheet is open, its loaded detail, and load state.
|
|
public private(set) var detailFor: Int?
|
|
public private(set) var detail: TorrentDetail?
|
|
public private(set) var detailLoading = false
|
|
public private(set) var detailError: String?
|
|
|
|
/// Open the debug sheet for a transfer: fetch its full snapshot on demand.
|
|
public func loadDetail(for id: Int) async {
|
|
detailFor = id
|
|
detailLoading = true
|
|
detailError = nil
|
|
do {
|
|
detail = try await service.detail(id)
|
|
} catch {
|
|
detail = nil
|
|
detailError = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription
|
|
Log.error("detail \(id) failed: \(detailError ?? "")")
|
|
}
|
|
detailLoading = false
|
|
}
|
|
|
|
public func closeDetail() { detailFor = nil; detail = nil; detailError = nil }
|
|
|
|
/// Run a recovery action, then refresh both the detail and the dashboard so the
|
|
/// effect (peers returning, recheck progress, paused state) shows immediately.
|
|
public func act(_ action: TorrentService.Action, on id: Int) async {
|
|
do {
|
|
try await service.act(action, on: id)
|
|
lastAction = "\(action.rawValue.capitalized): \(detail?.name ?? "id \(id)")"
|
|
Log.info("tx-\(action.rawValue) ok: id \(id)")
|
|
if detailFor == id { await loadDetail(for: id) }
|
|
await refreshTransfers()
|
|
} catch {
|
|
detailError = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription
|
|
lastAction = "\(action.rawValue.capitalized) failed"
|
|
Log.error("tx-\(action.rawValue) failed (id \(id)): \(detailError ?? "")")
|
|
}
|
|
}
|
|
|
|
/// Research/swap: remove the old torrent (keeping its data on disk) then add the
|
|
/// chosen better-seeded magnet (transmission will hash-check existing files into
|
|
/// the new torrent). Category inferred from downloadDir if possible.
|
|
public func replace(_ old: TorrentRow, with better: TorrentResult) async {
|
|
guard let magnet = better.magnet else { return }
|
|
do {
|
|
// Keep data so bytes are reused by the replacement torrent.
|
|
try await service.remove(old.id, deleteData: false)
|
|
// Re-add; try to preserve prior category from dir prefix.
|
|
var cat: String? = nil
|
|
if let dir = old.downloadDir {
|
|
for c in Self.addCategories {
|
|
if dir.contains("/\(c)/") || dir.hasSuffix("/\(c)") { cat = c; break }
|
|
}
|
|
}
|
|
try await service.add(magnet, category: cat)
|
|
lastAction = "Swapped to better release: \(better.filename)"
|
|
Log.info("replace ok: removed \(old.id) kept data; added \(better.filename) cat=\(cat ?? "default")")
|
|
if detailFor == old.id { closeDetail() }
|
|
await refreshTransfers()
|
|
} catch {
|
|
lastAction = "Swap failed: \((error as? LocalizedError)?.errorDescription ?? error.localizedDescription)"
|
|
Log.error("replace failed for id \(old.id): \(lastAction ?? "")")
|
|
if detailFor == old.id { detailError = lastAction }
|
|
}
|
|
}
|
|
}
|