transmission_rpc/types/
response.rs

1use std::{collections::HashMap, net::IpAddr};
2
3use base64::{engine::general_purpose::STANDARD as base64, Engine as _};
4use chrono::serde::ts_seconds::deserialize as from_ts;
5use chrono::{DateTime, Utc};
6use serde::de::{Deserializer, Error as _};
7use serde::Deserialize;
8use serde_json::Value;
9use serde_repr::*;
10
11use crate::types::request::{IdleMode, Priority, RatioMode};
12use crate::types::Id;
13
14#[derive(Deserialize, Debug)]
15pub struct RpcResponse<T: RpcResponseArgument> {
16    pub arguments: T,
17    pub result: String,
18}
19
20impl<T: RpcResponseArgument> RpcResponse<T> {
21    pub fn is_ok(&self) -> bool {
22        self.result == "success"
23    }
24}
25pub trait RpcResponseArgument {}
26
27#[derive(Deserialize, Debug, Clone)]
28pub struct SessionSet {}
29impl RpcResponseArgument for SessionSet {}
30
31#[derive(Deserialize, Debug, Clone)]
32#[serde(rename_all = "kebab-case")]
33pub struct SessionGet {
34    pub blocklist_enabled: bool,
35    pub download_dir: String,
36    pub encryption: String,
37    pub peer_port: i32,
38    pub rpc_version: i32,
39    pub rpc_version_minimum: i32,
40    pub version: String,
41}
42impl RpcResponseArgument for SessionGet {}
43
44#[derive(Deserialize, Debug, Clone)]
45#[serde(rename_all = "camelCase")]
46pub struct SessionStats {
47    pub torrent_count: i32,
48    pub active_torrent_count: i32,
49    pub paused_torrent_count: i32,
50    pub download_speed: i64,
51    pub upload_speed: i64,
52    #[serde(rename = "current-stats")]
53    pub current_stats: Stats,
54    #[serde(rename = "cumulative-stats")]
55    pub cumulative_stats: Stats,
56}
57impl RpcResponseArgument for SessionStats {}
58
59#[derive(Deserialize, Debug, Clone)]
60pub struct SessionClose {}
61impl RpcResponseArgument for SessionClose {}
62
63#[derive(Deserialize, Debug, Clone)]
64#[serde(rename_all = "kebab-case")]
65pub struct BlocklistUpdate {
66    pub blocklist_size: Option<i32>,
67}
68impl RpcResponseArgument for BlocklistUpdate {}
69
70#[derive(Deserialize, Debug, Clone)]
71#[serde(rename_all = "kebab-case")]
72pub struct FreeSpace {
73    pub path: String,
74    pub size_bytes: i64,
75}
76impl RpcResponseArgument for FreeSpace {}
77
78#[derive(Deserialize, Debug, Clone)]
79#[serde(rename_all = "kebab-case")]
80pub struct PortTest {
81    pub port_is_open: bool,
82}
83impl RpcResponseArgument for PortTest {}
84
85#[derive(Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Debug, Deserialize_repr)]
86#[repr(u8)]
87pub enum TorrentStatus {
88    Stopped = 0,
89    QueuedToVerify = 1,
90    Verifying = 2,
91    QueuedToDownload = 3,
92    Downloading = 4,
93    QueuedToSeed = 5,
94    Seeding = 6,
95}
96
97#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Debug, Deserialize_repr)]
98#[repr(u8)]
99pub enum ErrorType {
100    Ok = 0,
101    TrackerWarning = 1,
102    TrackerError = 2,
103    LocalError = 3,
104}
105
106#[derive(Deserialize, Debug, Clone)]
107#[serde(rename_all = "camelCase")]
108pub struct Torrent {
109    #[serde(deserialize_with = "from_ts_option", default)]
110    pub activity_date: Option<DateTime<Utc>>,
111    #[serde(deserialize_with = "from_ts_option", default)]
112    pub added_date: Option<DateTime<Utc>>,
113    /// "An array of `pieceCount` numbers representing the number of connected peers that have each
114    /// piece, or -1 if we already have the piece ourselves."
115    ///
116    /// Added in Transmission 4.0.0 (`rpc-version-semver`: 5.3.0, `rpc-version`: 17).
117    pub availability: Option<Vec<i16>>,
118    pub bandwidth_priority: Option<Priority>,
119    pub comment: Option<String>,
120    pub corrupt_ever: Option<u64>,
121    pub creator: Option<String>,
122    #[serde(deserialize_with = "from_ts_option", default)]
123    pub date_created: Option<DateTime<Utc>>,
124    pub desired_available: Option<u64>,
125    #[serde(deserialize_with = "from_ts_option", default)]
126    pub done_date: Option<DateTime<Utc>>,
127    pub download_dir: Option<String>,
128    pub downloaded_ever: Option<u64>,
129    pub download_limit: Option<u64>,
130    pub download_limited: Option<bool>,
131    #[serde(deserialize_with = "from_ts_option", default)]
132    pub edit_date: Option<DateTime<Utc>>,
133    pub error: Option<ErrorType>,
134    pub error_string: Option<String>,
135    pub eta: Option<i64>,
136    pub eta_idle: Option<i64>,
137    pub group: Option<String>,
138    pub hash_string: Option<String>,
139    pub have_unchecked: Option<u64>,
140    pub have_valid: Option<u64>,
141    pub honors_session_limits: Option<bool>,
142    pub id: Option<i64>,
143    pub is_finished: Option<bool>,
144    pub is_private: Option<bool>,
145    pub is_stalled: Option<bool>,
146    pub labels: Option<Vec<String>>,
147    pub left_until_done: Option<i64>,
148    pub magnet_link: Option<String>,
149    /// [`DateTime::UNIX_EPOCH`] if never manually announced.
150    #[serde(deserialize_with = "from_ts_option", default)]
151    pub manual_announce_time: Option<DateTime<Utc>>,
152    pub max_connected_peers: Option<u16>,
153    pub metadata_percent_complete: Option<f32>,
154    pub name: Option<String>,
155    #[serde(rename = "peer-limit")]
156    pub peer_limit: Option<u16>,
157    pub peers: Option<Vec<Peer>>,
158    pub peers_connected: Option<i64>,
159    pub peers_from: Option<PeersFrom>,
160    pub peers_getting_from_us: Option<i64>,
161    pub peers_sending_to_us: Option<i64>,
162    pub percent_complete: Option<f32>,
163    pub percent_done: Option<f32>,
164    /// "A bitfield holding `pieceCount` flags which are set to 'true' if we have the piece
165    /// matching that position. JSON doesn't allow raw binary data, so this is a base64-encoded
166    /// string."
167    #[serde(deserialize_with = "from_bitfield_option", default)]
168    pub pieces: Option<Vec<u8>>,
169    pub piece_count: Option<u64>,
170    pub piece_size: Option<u64>,
171    #[serde(rename = "primary-mime-type")]
172    pub primary_mime_type: Option<String>,
173    pub queue_position: Option<usize>,
174    pub rate_download: Option<i64>,
175    pub rate_upload: Option<i64>,
176    pub recheck_progress: Option<f32>,
177    pub seconds_downloading: Option<u64>,
178    pub seconds_seeding: Option<i64>,
179    pub seed_idle_limit: Option<u64>, // Can this be negative?
180    pub seed_idle_mode: Option<IdleMode>,
181    pub seed_ratio_limit: Option<f32>,
182    pub seed_ratio_mode: Option<RatioMode>,
183    pub sequential_download: Option<bool>,
184    pub size_when_done: Option<i64>,
185    #[serde(deserialize_with = "from_ts_option", default)]
186    pub start_date: Option<DateTime<Utc>>,
187    pub status: Option<TorrentStatus>,
188    pub torrent_file: Option<String>,
189    pub total_size: Option<i64>,
190    pub trackers: Option<Vec<Trackers>>,
191    pub tracker_list: Option<String>,
192    pub tracker_stats: Option<Vec<TrackerStat>>,
193    pub upload_ratio: Option<f32>,
194    pub uploaded_ever: Option<i64>,
195    pub upload_limit: Option<u64>, // Can this be negative?
196    pub upload_limited: Option<bool>,
197    pub files: Option<Vec<File>>,
198    /// Each element represents whether the corresponding file in [`files`] will be downloaded
199    /// (`true`) or not (`false`).
200    ///
201    /// [`files`]: Torrent::files
202    #[serde(deserialize_with = "from_arr_bool_option", default)]
203    pub wanted: Option<Vec<bool>>,
204    pub webseeds: Option<Vec<String>>,
205    pub webseeds_sending_to_us: Option<u16>,
206    pub priorities: Option<Vec<Priority>>,
207    pub file_stats: Option<Vec<FileStat>>,
208    #[serde(rename = "file-count")]
209    pub file_count: Option<usize>,
210}
211
212fn from_ts_option<'de, D>(deserializer: D) -> Result<Option<DateTime<Utc>>, D::Error>
213where
214    D: Deserializer<'de>,
215{
216    let ts: i64 = Deserialize::deserialize(deserializer)?;
217    // The transmission rpc server responds with 0 or -1 (in the case of manualAnnounceTime) when
218    // the date is unset or invalid.
219    // Consolidate any response <= 0 as UNIX_EPOCH to denote these cases.
220    if ts <= 0 {
221        return Ok(Some(DateTime::UNIX_EPOCH));
222    }
223    Ok(DateTime::<Utc>::from_timestamp(ts, 0))
224}
225
226/// Attempts to deserialize a [`base64`]-encoded string into a `Vec<u8>`.
227fn from_bitfield_option<'de, D>(deserializer: D) -> Result<Option<Vec<u8>>, D::Error>
228where
229    D: Deserializer<'de>,
230{
231    let encoded: &str = Deserialize::deserialize(deserializer)?;
232    let bitfield = base64.decode(encoded).map_err(D::Error::custom)?;
233    Ok(Some(bitfield))
234}
235
236/// Attempts to deserialize an array of bools or ints (`0` or `1`) into a `Vec<bool>`, treating `0`
237/// as false and `1` as true.
238fn from_arr_bool_option<'de, D>(deserializer: D) -> Result<Option<Vec<bool>>, D::Error>
239where
240    D: Deserializer<'de>,
241{
242    let unexpected = || D::Error::custom("unexpected type");
243
244    let wanted = match Deserialize::deserialize(deserializer)? {
245        Value::Array(arr) => arr
246            .into_iter()
247            .map(|val| match val {
248                // transmission 5.0.0+ returns an array of booleans.
249                Value::Bool(b) => Ok(b),
250                // transmission 4.x.x and below returns an array of ints (1 true, 0 false).
251                Value::Number(num) => num.as_i64().map(|n| n == 1).ok_or_else(unexpected),
252                // rpc server misbehaving (got an unexpected type).
253                _ => Err(unexpected()),
254            })
255            .collect::<Result<Vec<_>, _>>()?,
256        // `wanted` should be an array.
257        _ => Err(unexpected())?,
258    };
259    Ok(Some(wanted))
260}
261
262impl Torrent {
263    /// Get either the ID or the hash string if exist, which are both unique and
264    /// can be pass to the API.
265    pub fn id(&self) -> Option<Id> {
266        self.id
267            .map(Id::Id)
268            .or_else(|| self.hash_string.clone().map(Id::Hash))
269    }
270}
271
272#[derive(Deserialize, Debug, Clone)]
273#[serde(rename_all = "camelCase")]
274pub struct Stats {
275    pub files_added: i32,
276    pub downloaded_bytes: i64,
277    pub uploaded_bytes: i64,
278    pub seconds_active: i64,
279    pub session_count: Option<i32>,
280}
281
282#[derive(Deserialize, Debug)]
283pub struct Torrents<T> {
284    pub torrents: Vec<T>,
285}
286impl RpcResponseArgument for Torrents<Torrent> {}
287
288#[derive(Deserialize, Debug, Clone)]
289pub struct Trackers {
290    pub id: i32,
291    pub announce: String,
292    pub scrape: String,
293    /// `the first label before the public suffix in the announce URL's host. eg.
294    /// "https://www.example.co.uk/announce"'s sitename is "example"`
295    /// Added in Transmission 4.0.0 (`rpc-version-semver`: 5.3.0, `rpc-version`: 17)
296    #[serde(default)]
297    pub sitename: String,
298    pub tier: usize,
299}
300
301#[derive(Deserialize, Debug, Clone)]
302#[serde(rename_all = "camelCase")]
303pub struct File {
304    /// "the total size of the file"
305    pub length: i64,
306    /// "the current size of the file, i.e. how much we've downloaded"
307    pub bytes_completed: i64,
308    /// "This file's name. Includes the full subpath in the torrent."
309    pub name: String,
310    /// "piece index where this file starts"
311    ///
312    /// Should be `Some(_)` if the Transmission version >= `4.1.0`, `None` if the version is less
313    /// than `4.1.0`.
314    ///
315    /// Added in Transmission `4.1.0` (`rpc-version-semver` 5.4.0, `rpc-version`: 18).
316    pub begin_piece: Option<u64>,
317    /// "piece index where this file ends (exclusive)"
318    ///
319    /// See [`begin_piece`](File::begin_piece).
320    ///
321    /// Added in Transmission `4.1.0` (`rpc-version-semver` 5.4.0, `rpc-version`: 18).
322    pub end_piece: Option<u64>,
323}
324
325#[derive(Deserialize, Debug, Clone)]
326#[serde(rename_all = "camelCase")]
327pub struct FileStat {
328    pub bytes_completed: i64,
329    pub wanted: bool,
330    pub priority: Priority,
331}
332
333#[derive(Deserialize, Debug, Clone)]
334#[serde(rename_all = "camelCase")]
335pub struct Peer {
336    // FIXME? serde doesn't like simplified ipv6 addresses
337    // FIXME- (does transmission emit simplified ipv6? eg. "::1")
338    pub address: IpAddr,
339    pub client_name: String,
340    pub client_is_choked: bool,
341    pub client_is_interested: bool,
342    pub flag_str: String,
343    pub is_downloading_from: bool,
344    pub is_encrypted: bool,
345    pub is_incoming: bool,
346    pub is_uploading_to: bool,
347    #[serde(rename = "isUTP")]
348    pub is_utp: bool,
349    pub peer_is_choked: bool,
350    pub peer_is_interested: bool,
351    pub port: u16,
352    pub progress: f32,
353    pub rate_to_client: u64, // (B/s)
354    pub rate_to_peer: u64,   // (B/s)
355}
356
357#[derive(Deserialize, Debug, Clone)]
358#[serde(rename_all = "camelCase")]
359pub struct PeersFrom {
360    pub from_cache: u16,
361    pub from_dht: u16,
362    pub from_incoming: u16,
363    pub from_lpd: u16,
364    pub from_ltep: u16,
365    pub from_pex: u16,
366    pub from_tracker: u16,
367}
368
369#[derive(Deserialize, Debug, Clone)]
370#[serde(rename_all = "camelCase")]
371pub struct TrackerStat {
372    pub announce_state: TrackerState,
373    pub announce: String,
374    pub download_count: i64,
375    pub has_announced: bool,
376    pub has_scraped: bool,
377    pub host: String,
378    pub id: Id,
379    pub is_backup: bool,
380    pub last_announce_peer_count: i64,
381    pub last_announce_result: String,
382    #[serde(deserialize_with = "from_ts")]
383    pub last_announce_start_time: DateTime<Utc>,
384    pub last_announce_succeeded: bool,
385    #[serde(deserialize_with = "from_ts")]
386    pub last_announce_time: DateTime<Utc>,
387    pub last_announce_timed_out: bool,
388    pub last_scrape_result: String,
389    #[serde(deserialize_with = "from_ts")]
390    pub last_scrape_start_time: DateTime<Utc>,
391    pub last_scrape_succeeded: bool,
392    #[serde(deserialize_with = "from_ts")]
393    pub last_scrape_time: DateTime<Utc>,
394    pub last_scrape_timed_out: bool,
395    pub leecher_count: i64,
396    #[serde(deserialize_with = "from_ts")]
397    pub next_announce_time: DateTime<Utc>,
398    #[serde(deserialize_with = "from_ts")]
399    pub next_scrape_time: DateTime<Utc>,
400    pub scrape_state: TrackerState,
401    pub scrape: String,
402    pub seeder_count: i64,
403    /// `the first label before the public suffix in the announce URL's host. eg.
404    /// "https://www.example.co.uk/announce"'s sitename is "example"`
405    /// Added in Transmission 4.0.0 (`rpc-version-semver`: 5.3.0, `rpc-version`: 17)
406    #[serde(default)]
407    pub sitename: String,
408    pub tier: usize,
409}
410
411#[derive(Deserialize_repr, Debug, Clone)]
412#[repr(i8)]
413pub enum TrackerState {
414    Inactive = 0,
415    Waiting = 1,
416    Queued = 2,
417    Active = 3,
418}
419
420#[derive(Deserialize, Debug)]
421pub struct Nothing {}
422impl RpcResponseArgument for Nothing {}
423
424#[derive(Debug)]
425pub enum TorrentAddedOrDuplicate {
426    TorrentDuplicate(Torrent),
427    TorrentAdded(Torrent),
428    Error,
429}
430
431impl RpcResponseArgument for TorrentAddedOrDuplicate {}
432
433impl<'de> Deserialize<'de> for TorrentAddedOrDuplicate {
434    fn deserialize<D>(deserializer: D) -> std::result::Result<Self, <D as Deserializer<'de>>::Error>
435    where
436        D: Deserializer<'de>,
437    {
438        let mut res: HashMap<String, Torrent> = Deserialize::deserialize(deserializer)?;
439
440        let added = res.remove("torrent-added");
441        let duplicate = res.remove("torrent-duplicate");
442        match (added, duplicate) {
443            (Some(torrent), None) => Ok(TorrentAddedOrDuplicate::TorrentAdded(torrent)),
444            (None, Some(torrent)) => Ok(TorrentAddedOrDuplicate::TorrentDuplicate(torrent)),
445            _ => Ok(TorrentAddedOrDuplicate::Error),
446        }
447    }
448}
449
450#[derive(Deserialize, Debug)]
451pub struct TorrentRenamePath {
452    pub path: Option<String>,
453    pub name: Option<String>,
454    pub id: Option<i64>,
455}
456impl RpcResponseArgument for TorrentRenamePath {}
457
458#[cfg(test)]
459mod tests {
460    use crate::types::{Result, RpcResponse, TorrentAddedOrDuplicate};
461    use serde_json;
462    use serde_json::Value;
463
464    #[test]
465    fn test_torrent_added_failure_with_torrent_added_or_duplicate() {
466        let v: RpcResponse<TorrentAddedOrDuplicate> =
467            serde_json::from_str(torrent_added_failure()).expect("Failure expected");
468        println!("{v:#?}");
469        assert!(!v.is_ok());
470    }
471
472    #[test]
473    fn test_torrent_added_success_with_torrent_added_or_duplicate() -> Result<()> {
474        let v: RpcResponse<TorrentAddedOrDuplicate> =
475            serde_json::from_str(torrent_added_success())?;
476        println!("{v:#?}");
477        Ok(())
478    }
479
480    #[test]
481    fn test_torrent_added_success_with_value() -> Result<()> {
482        let v: Value = serde_json::from_str(torrent_added_success())?;
483        println!("{v:?} {}", serde_json::to_string_pretty(&v).expect(""));
484        Ok(())
485    }
486
487    #[test]
488    fn test_torrent_added_failure_with_value() -> Result<()> {
489        let v: Value = serde_json::from_str(torrent_added_failure())?;
490        println!("{v:?} {}", serde_json::to_string_pretty(&v).expect(""));
491        Ok(())
492    }
493
494    fn torrent_added_success() -> &'static str {
495        r#"
496        {
497            "arguments": {
498                "torrent-added": {
499                    "hashString": "bbdaece7c8daa85e1619469ab25d422a612cf923",
500                    "id": 2,
501                    "name": "toto.torrent"}
502                },
503            "result": "success"
504        }
505        "#
506    }
507
508    fn torrent_added_failure() -> &'static str {
509        r#"
510        {
511            "arguments": {},
512            "result": "download directory path is not absolute"
513        }
514        "#
515    }
516}