btpc_core/
magnet.rs

1//! Deterministic magnet URI generation.
2
3use std::fmt::Write as _;
4
5use crate::Metainfo;
6
7/// Optional magnet URI fields.
8#[derive(Clone, Copy, Debug, Eq, PartialEq)]
9pub struct MagnetOptions {
10    display_name: bool,
11    trackers: bool,
12    web_seeds: bool,
13}
14
15impl Default for MagnetOptions {
16    fn default() -> Self {
17        Self {
18            display_name: true,
19            trackers: true,
20            web_seeds: true,
21        }
22    }
23}
24
25impl MagnetOptions {
26    /// Starts an options builder.
27    #[must_use]
28    pub const fn builder() -> MagnetOptionsBuilder {
29        MagnetOptionsBuilder(Self {
30            display_name: true,
31            trackers: true,
32            web_seeds: true,
33        })
34    }
35}
36
37/// Builder for [`MagnetOptions`].
38#[derive(Clone, Copy, Debug)]
39pub struct MagnetOptionsBuilder(MagnetOptions);
40
41impl MagnetOptionsBuilder {
42    /// Includes or omits the display name.
43    #[must_use]
44    pub const fn display_name(mut self, include: bool) -> Self {
45        self.0.display_name = include;
46        self
47    }
48
49    /// Includes or omits tracker parameters.
50    #[must_use]
51    pub const fn trackers(mut self, include: bool) -> Self {
52        self.0.trackers = include;
53        self
54    }
55
56    /// Includes or omits web seed parameters.
57    #[must_use]
58    pub const fn web_seeds(mut self, include: bool) -> Self {
59        self.0.web_seeds = include;
60        self
61    }
62
63    /// Builds magnet options.
64    #[must_use]
65    pub const fn build(self) -> MagnetOptions {
66        self.0
67    }
68}
69
70/// Generates a deterministic magnet URI from validated metainfo.
71#[must_use]
72pub fn generate(metainfo: &Metainfo, options: &MagnetOptions) -> String {
73    let mut parameters = Vec::new();
74    if let Some(hash) = metainfo.info_hash_v1() {
75        parameters.push(format!("xt=urn:btih:{}", hash.hex()));
76    }
77    if let Some(hash) = metainfo.info_hash_v2() {
78        parameters.push(format!("xt=urn:btmh:1220{}", hash.hex()));
79    }
80    if options.display_name {
81        parameters.push(format!("dn={}", percent_encode(metainfo.name())));
82    }
83    if options.trackers {
84        for tracker in metainfo.trackers().iter().flatten() {
85            parameters.push(format!("tr={}", percent_encode(tracker)));
86        }
87    }
88    if options.web_seeds {
89        for seed in metainfo.web_seeds() {
90            parameters.push(format!("ws={}", percent_encode(seed)));
91        }
92    }
93    format!("magnet:?{}", parameters.join("&"))
94}
95
96fn percent_encode(bytes: &[u8]) -> String {
97    bytes.iter().fold(String::new(), |mut output, byte| {
98        if byte.is_ascii_alphanumeric() || matches!(*byte, b'-' | b'.' | b'_' | b'~') {
99            output.push(char::from(*byte));
100        } else {
101            write!(output, "%{byte:02X}").expect("writing to String cannot fail");
102        }
103        output
104    })
105}