Skip to content

Commit 0f3cb56

Browse files
committed
Handle Transmission upload ratio sentinels in torrent UI
1 parent cbcc62e commit 0f3cb56

6 files changed

Lines changed: 220 additions & 10 deletions

File tree

BitDream/Transmission/TransmissionTorrentModels.swift

Lines changed: 84 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,84 @@ public enum TorrentStatusCalc: String, CaseIterable {
4848
case unknown = "Unknown"
4949
}
5050

51+
enum TorrentUploadRatio: Equatable, Sendable {
52+
case unavailable
53+
case infinite
54+
case value(Double)
55+
56+
// Transmission uses raw sentinel values here: -1 means "ratio unavailable"
57+
// and -2 means "uploaded without any recorded download history."
58+
private static let unavailableRawValue = -1.0
59+
private static let infiniteRawValue = -2.0
60+
61+
init(rawValue: Double) {
62+
if rawValue == Self.unavailableRawValue {
63+
self = .unavailable
64+
} else if rawValue == Self.infiniteRawValue {
65+
self = .infinite
66+
} else {
67+
self = .value(rawValue)
68+
}
69+
}
70+
71+
var displayValue: Double {
72+
switch self {
73+
case .unavailable:
74+
// No ratio to show yet, so keep the ring empty.
75+
return 0
76+
case .infinite:
77+
// The chip caps out at a full ring, so this shows as complete.
78+
return 1
79+
case .value(let value):
80+
return value
81+
}
82+
}
83+
84+
var displayText: String {
85+
switch self {
86+
case .unavailable:
87+
// Avoid pretending this is a real numeric ratio.
88+
return "None"
89+
case .infinite:
90+
// Keep this readable without surfacing the raw sentinel value.
91+
return "1.00+"
92+
case .value(let value):
93+
return String(format: "%.2f", value)
94+
}
95+
}
96+
97+
var ringProgressValue: Double {
98+
switch self {
99+
case .unavailable:
100+
return 0
101+
case .infinite:
102+
return 1
103+
case .value(let value):
104+
return min(value, 1.0)
105+
}
106+
}
107+
108+
var usesCompletionColor: Bool {
109+
switch self {
110+
case .infinite:
111+
return true
112+
case .unavailable:
113+
return false
114+
case .value(let value):
115+
return value >= 1.0
116+
}
117+
}
118+
119+
var isAvailable: Bool {
120+
switch self {
121+
case .unavailable:
122+
return false
123+
case .infinite, .value:
124+
return true
125+
}
126+
}
127+
}
128+
51129
// MARK: - Generic Request/Response Models
52130

53131
/// Generic request struct for all Transmission RPC methods
@@ -92,10 +170,14 @@ public struct Torrent: Codable, Hashable, Identifiable, Sendable {
92170
let sizeWhenDone: Int64
93171
let status: Int
94172
let totalSize: Int64
95-
let uploadRatio: Double
173+
// Keep the raw RPC value so we do not lose which sentinel Transmission sent.
174+
let uploadRatioRaw: Double
96175
let uploadedEver: Int64
97176
let downloadedEver: Int64
98177
var downloadedCalc: Int64 { haveUnchecked + haveValid}
178+
// Views should use the interpreted ratio state instead of reading the raw
179+
// RPC value directly.
180+
var uploadRatio: TorrentUploadRatio { TorrentUploadRatio(rawValue: uploadRatioRaw) }
99181
var statusCalc: TorrentStatusCalc {
100182
if status == TorrentStatus.stopped.rawValue && percentDone == 1 {
101183
return TorrentStatusCalc.complete
@@ -159,7 +241,7 @@ public struct Torrent: Codable, Hashable, Identifiable, Sendable {
159241
case sizeWhenDone
160242
case status
161243
case totalSize
162-
case uploadRatio
244+
case uploadRatioRaw = "uploadRatio"
163245
case uploadedEver
164246
case downloadedEver
165247
}

BitDream/Views/Shared/SharedComponents.swift

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -103,10 +103,30 @@ struct SpeedChip: View {
103103
// MARK: - RatioChip Component
104104

105105
struct RatioChip: View {
106-
let ratio: Double
106+
private let ringProgress: Double
107+
private let displayText: String
108+
private let showsCompletionColor: Bool
107109
var size: SpeedChipSize = .compact
108110
var helpText: String?
109111

112+
init(ratio: Double, size: SpeedChipSize = .compact, helpText: String? = nil) {
113+
self.ringProgress = min(ratio, 1.0)
114+
self.displayText = String(format: "%.2f", ratio)
115+
self.showsCompletionColor = ratio >= 1.0
116+
self.size = size
117+
self.helpText = helpText
118+
}
119+
120+
init(uploadRatio: TorrentUploadRatio, size: SpeedChipSize = .compact, helpText: String? = nil) {
121+
// Torrent ratios can come through as raw sentinel values, so the chip
122+
// takes the already-interpreted state here.
123+
self.ringProgress = uploadRatio.ringProgressValue
124+
self.displayText = uploadRatio.displayText
125+
self.showsCompletionColor = uploadRatio.usesCompletionColor
126+
self.size = size
127+
self.helpText = helpText
128+
}
129+
110130
private var progressRingSize: CGFloat {
111131
switch size {
112132
case .compact: return 14
@@ -122,13 +142,13 @@ struct RatioChip: View {
122142
.frame(width: progressRingSize, height: progressRingSize)
123143

124144
Circle()
125-
.trim(from: 0, to: min(ratio, 1.0))
126-
.stroke(ratio >= 1.0 ? .green : .orange, lineWidth: 2)
145+
.trim(from: 0, to: ringProgress)
146+
.stroke(showsCompletionColor ? .green : .orange, lineWidth: 2)
127147
.frame(width: progressRingSize, height: progressRingSize)
128148
.rotationEffect(.degrees(-90))
129149
}
130150

131-
Text(String(format: "%.2f", ratio))
151+
Text(displayText)
132152
.monospacedDigit()
133153
}
134154
.font(size.font)

BitDream/Views/Shared/TorrentDetail.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -573,7 +573,7 @@ func formatTorrentDetails(torrent: Torrent) -> TorrentDetailsDisplay {
573573
let downloadedFormatted = formatByteCount(torrent.downloadedCalc)
574574
let sizeWhenDoneFormatted = formatByteCount(torrent.sizeWhenDone)
575575
let uploadedFormatted = formatByteCount(torrent.uploadedEver)
576-
let uploadRatio = String(format: "%.2f", torrent.uploadRatio)
576+
let uploadRatio = torrent.uploadRatio.displayText
577577

578578
let activityDate = formatTorrentDetailDate(torrent.activityDate)
579579
let addedDate = formatTorrentDetailDate(torrent.addedDate)
@@ -600,7 +600,7 @@ struct TorrentDetailHeaderView: View {
600600

601601
HStack(spacing: 8) {
602602
RatioChip(
603-
ratio: torrent.uploadRatio,
603+
uploadRatio: torrent.uploadRatio,
604604
size: .compact
605605
)
606606

BitDreamTests/Transmission/Connection/TransmissionConnectionQueryTests.swift

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,10 @@ final class TransmissionConnectionQueryTests: XCTestCase {
1515
let torrents = try await connection.fetchTorrentSummary()
1616

1717
XCTAssertFalse(torrents.isEmpty)
18+
let sampleTorrent = try XCTUnwrap(torrents.first(where: { $0.id == 2 }))
19+
XCTAssertEqual(sampleTorrent.uploadRatioRaw, 0)
20+
XCTAssertEqual(sampleTorrent.uploadRatio, .value(0))
21+
XCTAssertEqual(sampleTorrent.uploadRatio.displayText, "0.00")
1822
let requests = await sender.capturedRequests()
1923
XCTAssertEqual(try capturedRequestFields(requests[0]), TransmissionTorrentQuerySpec.torrentSummary.fields)
2024
}
@@ -35,6 +39,110 @@ final class TransmissionConnectionQueryTests: XCTestCase {
3539
XCTAssertEqual(try capturedRequestFields(requests[0]), TransmissionTorrentQuerySpec.widgetSummary.fields)
3640
}
3741

42+
func testFetchTorrentSummaryDistinguishesUnavailableAndInfiniteRawRatioValues() async throws {
43+
let sender = QueueSender(steps: [
44+
.http(
45+
statusCode: 200,
46+
body: """
47+
{
48+
"arguments": {
49+
"torrents": [
50+
{
51+
"activityDate": 0,
52+
"addedDate": 0,
53+
"desiredAvailable": 0,
54+
"error": 0,
55+
"errorString": "",
56+
"eta": 0,
57+
"haveUnchecked": 0,
58+
"haveValid": 0,
59+
"id": 1,
60+
"isFinished": false,
61+
"isStalled": false,
62+
"labels": [],
63+
"leftUntilDone": 0,
64+
"magnetLink": "",
65+
"metadataPercentComplete": 1,
66+
"name": "Unavailable",
67+
"peersConnected": 0,
68+
"peersGettingFromUs": 0,
69+
"peersSendingToUs": 0,
70+
"percentDone": 0,
71+
"primary-mime-type": null,
72+
"downloadDir": "/downloads",
73+
"queuePosition": 0,
74+
"rateDownload": 0,
75+
"rateUpload": 0,
76+
"sizeWhenDone": 0,
77+
"status": 0,
78+
"totalSize": 0,
79+
"uploadRatio": -1,
80+
"uploadedEver": 0,
81+
"downloadedEver": 0
82+
},
83+
{
84+
"activityDate": 0,
85+
"addedDate": 0,
86+
"desiredAvailable": 0,
87+
"error": 0,
88+
"errorString": "",
89+
"eta": 0,
90+
"haveUnchecked": 0,
91+
"haveValid": 0,
92+
"id": 2,
93+
"isFinished": false,
94+
"isStalled": false,
95+
"labels": [],
96+
"leftUntilDone": 0,
97+
"magnetLink": "",
98+
"metadataPercentComplete": 1,
99+
"name": "Infinite",
100+
"peersConnected": 0,
101+
"peersGettingFromUs": 0,
102+
"peersSendingToUs": 0,
103+
"percentDone": 0,
104+
"primary-mime-type": null,
105+
"downloadDir": "/downloads",
106+
"queuePosition": 0,
107+
"rateDownload": 0,
108+
"rateUpload": 0,
109+
"sizeWhenDone": 0,
110+
"status": 0,
111+
"totalSize": 0,
112+
"uploadRatio": -2,
113+
"uploadedEver": 1,
114+
"downloadedEver": 0
115+
}
116+
]
117+
},
118+
"result": "success"
119+
}
120+
"""
121+
)
122+
])
123+
let connection = TransmissionConnection(
124+
endpoint: try makeEndpoint(),
125+
auth: makeAuth(),
126+
transport: TransmissionTransport(sender: sender)
127+
)
128+
129+
let torrents = try await connection.fetchTorrentSummary()
130+
131+
let unavailableTorrent = try XCTUnwrap(torrents.first(where: { $0.id == 1 }))
132+
XCTAssertEqual(unavailableTorrent.uploadRatioRaw, -1)
133+
XCTAssertEqual(unavailableTorrent.uploadRatio, .unavailable)
134+
XCTAssertEqual(unavailableTorrent.uploadRatio.displayText, "None")
135+
XCTAssertEqual(unavailableTorrent.uploadRatio.ringProgressValue, 0)
136+
XCTAssertFalse(unavailableTorrent.uploadRatio.usesCompletionColor)
137+
138+
let infiniteTorrent = try XCTUnwrap(torrents.first(where: { $0.id == 2 }))
139+
XCTAssertEqual(infiniteTorrent.uploadRatioRaw, -2)
140+
XCTAssertEqual(infiniteTorrent.uploadRatio, .infinite)
141+
XCTAssertEqual(infiniteTorrent.uploadRatio.displayText, "1.00+")
142+
XCTAssertEqual(infiniteTorrent.uploadRatio.ringProgressValue, 1)
143+
XCTAssertTrue(infiniteTorrent.uploadRatio.usesCompletionColor)
144+
}
145+
38146
func testFetchTorrentFilesUsesNamedFieldsAndDecodesFirstTorrent() async throws {
39147
let sender = QueueSender(steps: [
40148
.http(

BitDreamTests/TransmissionStore/TransmissionStorePlaybackOperationTests.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,7 @@ private extension TransmissionStorePlaybackOperationTests {
116116
sizeWhenDone: 0,
117117
status: status.rawValue,
118118
totalSize: 0,
119-
uploadRatio: 0,
119+
uploadRatioRaw: 0,
120120
uploadedEver: 0,
121121
downloadedEver: 0
122122
)

BitDreamTests/Views/TorrentBulkLabelEditTests.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -142,7 +142,7 @@ private extension TorrentBulkLabelEditTests {
142142
sizeWhenDone: 0,
143143
status: TorrentStatus.stopped.rawValue,
144144
totalSize: 0,
145-
uploadRatio: 0,
145+
uploadRatioRaw: 0,
146146
uploadedEver: 0,
147147
downloadedEver: 0
148148
)

0 commit comments

Comments
 (0)