205 lines
9.2 KiB
Swift
205 lines
9.2 KiB
Swift
import Foundation
|
|
|
|
/// A torrent search hit — wire shape of `cli.ts search` (which reuses
|
|
/// tv-anarchy-mcp's `searchTorrents`). `magnet` is nil when the source didn't
|
|
/// expose one (then it can't be added).
|
|
public struct TorrentResult: Decodable, Sendable, Equatable, Identifiable {
|
|
public let filename: String
|
|
public let source: String
|
|
public let size: String
|
|
public let seeders: Int
|
|
public let leechers: Int
|
|
public let magnet: String?
|
|
|
|
/// Stable enough for SwiftUI identity within one result set.
|
|
public var id: String { "\(source)|\(filename)|\(size)" }
|
|
public var addable: Bool { magnet != nil }
|
|
}
|
|
|
|
/// A transfer's health — the spine that unifies download sorting, surfacing, and
|
|
/// the debug view. Mirrors the planned fleet reaper's `healthy | stalled | dead`.
|
|
public enum TransferHealth: String, Sendable, Equatable, CaseIterable {
|
|
case errored // transmission reported an error
|
|
case dead // downloading, 0 peers + 0 rate, stuck past the dead threshold
|
|
case stalled // downloading, 0 rate (peers present) past the stall threshold
|
|
case checking
|
|
case queued
|
|
case downloading
|
|
case seeding
|
|
case done
|
|
case stopped
|
|
|
|
/// Surfaces to the user as "needs attention" (badge + a notification on entry).
|
|
public var needsAttention: Bool { self == .errored || self == .dead || self == .stalled }
|
|
|
|
/// Sort priority within the Active section: attention first, then active work,
|
|
/// then idle. (Completed/seeding live in their own sections.)
|
|
public var sortRank: Int {
|
|
switch self {
|
|
case .errored: 0; case .dead: 1; case .stalled: 2
|
|
case .downloading: 3; case .checking: 4; case .queued: 5
|
|
case .stopped: 6; case .seeding: 7; case .done: 8
|
|
}
|
|
}
|
|
|
|
/// Pure classifier. `secondsStuck` = how long rateDownload has been 0 (the
|
|
/// controller tracks this across polls); nil → not yet stalled.
|
|
public static func classify(status: Int, isComplete: Bool, rateDownload: Int,
|
|
peersConnected: Int?, error: Int?,
|
|
secondsStuck: TimeInterval?,
|
|
stallSeconds: TimeInterval = 120,
|
|
deadSeconds: TimeInterval = 300) -> TransferHealth {
|
|
// Transmission error codes: 0 ok · 1 tracker *warning* · 2 tracker error ·
|
|
// 3 local error. A warning (1) is one tracker flapping on an otherwise healthy
|
|
// multi-tracker torrent — NOT a failure, so don't red-flag/notify on it.
|
|
// ≥2 (tracker rejected / local disk-perm error) is genuine attention.
|
|
if (error ?? 0) >= 2 { return .errored }
|
|
switch status {
|
|
case 6: return .seeding
|
|
case 0: return isComplete ? .done : .stopped
|
|
case 1, 2: return .checking
|
|
case 3, 5: return .queued
|
|
case 4:
|
|
if rateDownload > 0 { return .downloading }
|
|
let stuck = secondsStuck ?? 0
|
|
if (peersConnected ?? 0) == 0 && stuck >= deadSeconds { return .dead }
|
|
if stuck >= stallSeconds { return .stalled }
|
|
return .downloading // briefly 0 rate, not yet stalled
|
|
default: return isComplete ? .done : .downloading
|
|
}
|
|
}
|
|
}
|
|
|
|
/// A tracker's view of one torrent — the "why" behind missing peers, shown in the
|
|
/// debug sheet (wire shape of `cli.ts tx-detail` → `trackerStats`).
|
|
public struct TrackerStat: Decodable, Sendable, Equatable, Identifiable {
|
|
public let host: String
|
|
public let announceState: Int
|
|
public let lastAnnounceResult: String
|
|
public let lastAnnounceSucceeded: Bool
|
|
public let seederCount: Int // -1 = unknown
|
|
public let leecherCount: Int
|
|
|
|
public var id: String { host }
|
|
/// Seeder/leecher reported by the tracker, or "—" when unknown (-1).
|
|
public var swarmText: String {
|
|
let s = seederCount >= 0 ? "\(seederCount)" : "—"
|
|
let l = leecherCount >= 0 ? "\(leecherCount)" : "—"
|
|
return "\(s) seed · \(l) leech"
|
|
}
|
|
public var resultText: String { lastAnnounceResult.isEmpty ? "—" : lastAnnounceResult }
|
|
}
|
|
|
|
/// Full debug snapshot for one transfer — fetched on demand when the detail sheet
|
|
/// opens (`cli.ts tx-detail <id>`). Heavier than the per-poll `TorrentRow`: adds
|
|
/// per-tracker stats, the peer breakdown, and the error string.
|
|
public struct TorrentDetail: Decodable, Sendable, Equatable, Identifiable {
|
|
public let id: Int
|
|
public let name: String
|
|
public let percentDone: Double
|
|
public let status: Int
|
|
public let error: Int
|
|
public let errorString: String
|
|
public let eta: Int
|
|
public let rateDownload: Int
|
|
public let rateUpload: Int
|
|
public let uploadRatio: Double
|
|
public let peersConnected: Int
|
|
public let peersSendingToUs: Int
|
|
public let peersGettingFromUs: Int
|
|
public let downloadDir: String
|
|
public let trackerStats: [TrackerStat]
|
|
|
|
public var progress: Double { min(1, max(0, percentDone)) }
|
|
public var isComplete: Bool { percentDone >= 1 }
|
|
public var hasError: Bool { error != 0 && !errorString.isEmpty }
|
|
/// "3 connected · ↓2 ↑1" — the live peer picture.
|
|
public var peersText: String {
|
|
"\(peersConnected) connected · ↓\(peersSendingToUs) ↑\(peersGettingFromUs)"
|
|
}
|
|
public func health(secondsStuck: TimeInterval? = nil) -> TransferHealth {
|
|
TransferHealth.classify(status: status, isComplete: isComplete, rateDownload: rateDownload,
|
|
peersConnected: peersConnected, error: error, secondsStuck: secondsStuck)
|
|
}
|
|
}
|
|
|
|
/// A transmission transfer — rich JSON-RPC shape from `cli.ts tx-list`
|
|
/// (`transmissionListRich`). Numbers, not pre-formatted strings, so the UI can
|
|
/// sort by completion time and render sizes/rates/relative times itself.
|
|
public struct TorrentRow: Decodable, Sendable, Equatable, Identifiable {
|
|
public let id: Int
|
|
public let name: String
|
|
public let percentDone: Double // 0..1
|
|
public let status: Int // 0 stopped,1/2 check,3/5 queued,4 dl,6 seed
|
|
public let doneDate: Int // unix epoch, 0 if never completed
|
|
public let addedDate: Int
|
|
public let haveValid: Int
|
|
public let sizeWhenDone: Int
|
|
public let rateDownload: Int // bytes/s
|
|
public let rateUpload: Int
|
|
public let uploadRatio: Double
|
|
public let eta: Int // seconds; negative = unknown/not-applicable
|
|
public let downloadDir: String? // black-side dir the files live under
|
|
// Health/debug fields — optional so a tx-list from a cli that doesn't yet emit
|
|
// them (pre-Part-E backend) still decodes; they default to "no error / unknown".
|
|
public let error: Int? // transmission error code (0/nil = none)
|
|
public let errorString: String?
|
|
public let peersConnected: Int?
|
|
|
|
public var progress: Double { min(1, max(0, percentDone)) }
|
|
public var isComplete: Bool { percentDone >= 1 }
|
|
public var isDownloading: Bool { status == 4 }
|
|
|
|
/// Health for sorting/surfacing/debug. `secondsStuck` (rate-zero-for) is tracked
|
|
/// by the controller across polls; nil = not yet known (treated as not-stalled).
|
|
public func health(secondsStuck: TimeInterval? = nil,
|
|
stallSeconds: TimeInterval = 120,
|
|
deadSeconds: TimeInterval = 300) -> TransferHealth {
|
|
TransferHealth.classify(status: status, isComplete: isComplete, rateDownload: rateDownload,
|
|
peersConnected: peersConnected, error: error,
|
|
secondsStuck: secondsStuck, stallSeconds: stallSeconds,
|
|
deadSeconds: deadSeconds)
|
|
}
|
|
|
|
/// The black-side folder holding this torrent's files (downloadDir/name), so a
|
|
/// finished download can be indexed incrementally instead of re-walking the
|
|
/// whole library. nil if transmission didn't report a downloadDir.
|
|
public var contentFolder: String? {
|
|
guard let d = downloadDir, !d.isEmpty else { return nil }
|
|
return d.hasSuffix("/") ? d + name : d + "/" + name
|
|
}
|
|
|
|
/// When the download finished, if it has.
|
|
public var completedAt: Date? { doneDate > 0 ? Date(timeIntervalSince1970: Double(doneDate)) : nil }
|
|
public var addedAt: Date? { addedDate > 0 ? Date(timeIntervalSince1970: Double(addedDate)) : nil }
|
|
|
|
public var statusLabel: String {
|
|
switch status {
|
|
case 0: return isComplete ? "Done" : "Stopped"
|
|
case 1, 2: return "Checking"
|
|
case 3, 5: return "Queued"
|
|
case 4: return "Downloading"
|
|
case 6: return "Seeding"
|
|
default: return "—"
|
|
}
|
|
}
|
|
|
|
public var sizeText: String { Self.bytes(sizeWhenDone) }
|
|
public var downText: String { rateDownload > 0 ? Self.bytes(rateDownload) + "/s" : "" }
|
|
public var upText: String { rateUpload > 0 ? Self.bytes(rateUpload) + "/s" : "" }
|
|
|
|
/// Remaining-time label for an active download.
|
|
public var etaText: String {
|
|
guard !isComplete, eta > 0 else { return "" }
|
|
let h = eta / 3600, m = (eta % 3600) / 60
|
|
if h > 0 { return "\(h)h \(m)m" }
|
|
if m > 0 { return "\(m)m" }
|
|
return "\(eta)s"
|
|
}
|
|
|
|
private static func bytes(_ n: Int) -> String {
|
|
let f = ByteCountFormatter()
|
|
f.countStyle = .file
|
|
return f.string(fromByteCount: Int64(n))
|
|
}
|
|
}
|