tv-anarchy/Sources/TVAnarchyCore/Torrents/TorrentModels.swift
Natalie e447f0a8f6 feat(@applications): add bridge deployment scripts
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
2026-06-09 22:22:56 -07:00

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))
}
}