1use std::fmt;
58use std::str::FromStr;
59
60use thiserror::Error;
61
62use crate::event::{Alphabet, Event, EventBuilder, Kind, SingleLetterTag, Tag, TagKind, Tags};
63use crate::types::{Url, UrlError};
64use crate::util::hex::{self, HexError};
65
66const INFO_HASH_LEN: usize = 20;
68
69const TCAT_PREFIX: &str = "tcat:";
71
72#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
79pub struct TorrentInfoHash([u8; INFO_HASH_LEN]);
80
81impl TorrentInfoHash {
82 #[must_use]
84 pub const fn from_byte_array(bytes: [u8; INFO_HASH_LEN]) -> Self {
85 Self(bytes)
86 }
87
88 #[must_use]
90 pub const fn as_byte_array(&self) -> &[u8; INFO_HASH_LEN] {
91 &self.0
92 }
93
94 #[must_use]
96 pub const fn to_byte_array(self) -> [u8; INFO_HASH_LEN] {
97 self.0
98 }
99
100 pub fn from_hex<S>(input: S) -> Result<Self, HexError>
106 where
107 S: AsRef<str>,
108 {
109 let mut bytes = [0_u8; INFO_HASH_LEN];
110 hex::decode_to_slice(input.as_ref(), &mut bytes)?;
111 Ok(Self(bytes))
112 }
113
114 #[must_use]
116 pub fn to_hex(self) -> String {
117 hex::encode(self.0)
118 }
119}
120
121impl fmt::Display for TorrentInfoHash {
122 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
123 hex::fmt_lower(self.0, f)
124 }
125}
126
127impl FromStr for TorrentInfoHash {
128 type Err = HexError;
129
130 fn from_str(s: &str) -> Result<Self, Self::Err> {
131 Self::from_hex(s)
132 }
133}
134
135#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
137pub struct TorrentFile {
138 pub name: String,
140 pub size: u64,
142}
143
144#[derive(Debug, Clone, PartialEq, Eq)]
146pub struct Torrent {
147 pub title: String,
149 pub description: String,
151 pub info_hash: TorrentInfoHash,
153 pub files: Vec<TorrentFile>,
155 pub trackers: Vec<Url>,
157 pub categories: Vec<String>,
159 pub external_ids: Vec<String>,
161 pub hashtags: Vec<String>,
163}
164
165impl Torrent {
166 #[must_use]
168 pub fn to_event_builder(&self) -> EventBuilder {
169 let mut tags: Vec<Tag> = Vec::with_capacity(
170 2 + self.files.len()
171 + self.trackers.len()
172 + usize::from(!self.categories.is_empty())
173 + self.external_ids.len()
174 + self.hashtags.len(),
175 );
176
177 tags.push(Tag::title(self.title.as_str()));
178 tags.push(Tag::with(&info_hash_kind(), [self.info_hash.to_hex()]));
179
180 for file in &self.files {
181 tags.push(Tag::with(
182 &file_kind(),
183 [file.name.clone(), file.size.to_string()],
184 ));
185 }
186 for tracker in &self.trackers {
187 tags.push(Tag::with(&tracker_kind(), [tracker.as_str().to_owned()]));
188 }
189 if !self.categories.is_empty() {
190 tags.push(Tag::i(format!(
191 "{TCAT_PREFIX}{}",
192 self.categories.join(",")
193 )));
194 }
195 for external in &self.external_ids {
196 tags.push(Tag::i(external.clone()));
197 }
198 for hashtag in &self.hashtags {
199 tags.push(Tag::t(hashtag));
200 }
201
202 EventBuilder::new(Kind::TORRENT, self.description.as_str()).tags(Tags::from_vec(tags))
203 }
204
205 pub fn from_event(event: &Event) -> Result<Self, TorrentError> {
218 if event.kind != Kind::TORRENT {
219 return Err(TorrentError::UnexpectedKind {
220 expected: Kind::TORRENT.as_u16(),
221 got: event.kind.as_u16(),
222 });
223 }
224
225 let title = event
226 .tags
227 .find_first(&TagKind::custom("title"))
228 .and_then(Tag::content)
229 .ok_or(TorrentError::MissingTitle)?
230 .to_owned();
231
232 let info_hash = event
233 .tags
234 .find_first(&info_hash_kind())
235 .and_then(Tag::content)
236 .ok_or(TorrentError::MissingInfoHash)
237 .and_then(|hex| {
238 TorrentInfoHash::from_hex(hex).map_err(TorrentError::InvalidInfoHash)
239 })?;
240
241 let mut files = Vec::new();
242 for tag in event.tags.find_all(&file_kind()) {
243 let name = tag.get(1).ok_or(TorrentError::MissingFileName)?.to_owned();
244 let size = tag
245 .get(2)
246 .ok_or(TorrentError::MissingFileSize)?
247 .parse::<u64>()
248 .map_err(|_err| TorrentError::InvalidFileSize)?;
249 files.push(TorrentFile { name, size });
250 }
251
252 let mut trackers = Vec::new();
253 for tag in event.tags.find_all(&tracker_kind()) {
254 let url = tag.content().ok_or(TorrentError::MissingTrackerUrl)?;
255 trackers.push(Url::parse(url)?);
256 }
257
258 let mut categories = Vec::new();
259 let mut external_ids = Vec::new();
260 for tag in event.tags.find_letter(Alphabet::I) {
261 let Some(value) = tag.content() else {
262 continue;
263 };
264 if let Some(path) = value.strip_prefix(TCAT_PREFIX) {
265 categories.extend(
266 path.split(',')
267 .filter(|segment| !segment.is_empty())
268 .map(str::to_owned),
269 );
270 } else {
271 external_ids.push(value.to_owned());
272 }
273 }
274
275 let hashtags = event
276 .tags
277 .find_letter(Alphabet::T)
278 .filter_map(Tag::content)
279 .map(str::to_owned)
280 .collect();
281
282 Ok(Self {
283 title,
284 description: event.content.clone(),
285 info_hash,
286 files,
287 trackers,
288 categories,
289 external_ids,
290 hashtags,
291 })
292 }
293}
294
295impl EventBuilder {
296 #[must_use]
302 pub fn torrent_comment<S>(content: S, torrent: &Event) -> Self
303 where
304 S: Into<String>,
305 {
306 Self::new(Kind::TORRENT_COMMENT, content)
307 .tag(Tag::e_marker(torrent.id, "", "root"))
308 .tag(Tag::p(torrent.pubkey))
309 }
310}
311
312const fn info_hash_kind() -> TagKind {
313 TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::X))
316}
317
318fn file_kind() -> TagKind {
319 TagKind::custom("file")
320}
321
322fn tracker_kind() -> TagKind {
323 TagKind::custom("tracker")
324}
325
326#[derive(Debug, Error)]
328#[non_exhaustive]
329pub enum TorrentError {
330 #[error("expected kind {expected}, got {got}")]
332 UnexpectedKind {
333 expected: u16,
335 got: u16,
337 },
338 #[error("torrent event is missing the `title` tag")]
340 MissingTitle,
341 #[error("torrent event is missing the `x` info-hash tag")]
343 MissingInfoHash,
344 #[error("invalid torrent info hash: {0}")]
346 InvalidInfoHash(#[source] HexError),
347 #[error("`file` tag is missing the file name")]
349 MissingFileName,
350 #[error("`file` tag is missing the file size")]
352 MissingFileSize,
353 #[error("`file` tag size is not a valid byte count")]
355 InvalidFileSize,
356 #[error("`tracker` tag is missing the URL")]
358 MissingTrackerUrl,
359 #[error(transparent)]
361 InvalidTracker(#[from] UrlError),
362}
363
364#[cfg(test)]
365mod tests {
366 use super::*;
367 use crate::Keys;
368
369 fn keys() -> Keys {
370 Keys::parse("0000000000000000000000000000000000000000000000000000000000000003").unwrap()
371 }
372
373 fn sample() -> Torrent {
374 Torrent {
375 title: "Example Release".to_owned(),
376 description: "A description body".to_owned(),
377 info_hash: TorrentInfoHash::from_hex("0123456789abcdef0123456789abcdef01234567")
378 .unwrap(),
379 files: vec![
380 TorrentFile {
381 name: "info/a.mkv".to_owned(),
382 size: 1_048_576,
383 },
384 TorrentFile {
385 name: "info/b.nfo".to_owned(),
386 size: 512,
387 },
388 ],
389 trackers: vec![
390 "udp://tracker.example:1337".parse().unwrap(),
391 "http://tracker.example/announce".parse().unwrap(),
392 ],
393 categories: vec!["video".to_owned(), "movie".to_owned(), "4k".to_owned()],
394 external_ids: vec!["imdb:tt15239678".to_owned(), "tmdb:movie:693134".to_owned()],
395 hashtags: vec!["movie".to_owned(), "4k".to_owned()],
396 }
397 }
398
399 #[test]
400 fn info_hash_hex_round_trip() {
401 let hex = "0123456789abcdef0123456789abcdef01234567";
402 let hash = TorrentInfoHash::from_hex(hex).unwrap();
403 assert_eq!(hash.to_hex(), hex);
404 assert_eq!(hash.to_string(), hex);
405 assert_eq!(hex.parse::<TorrentInfoHash>().unwrap(), hash);
406 }
407
408 #[test]
409 fn info_hash_rejects_wrong_length() {
410 assert!(TorrentInfoHash::from_hex("abcd").is_err());
411 assert!(TorrentInfoHash::from_hex("zz").is_err());
412 }
413
414 #[test]
415 fn round_trip_through_event() {
416 let torrent = sample();
417 let event = torrent.to_event_builder().sign_with_keys(&keys()).unwrap();
418 event.verify().unwrap();
419 assert_eq!(event.kind, Kind::TORRENT);
420
421 let parsed = Torrent::from_event(&event).unwrap();
422 assert_eq!(parsed, torrent);
423 }
424
425 #[test]
426 fn categories_emitted_as_single_tcat_tag() {
427 let event = sample().to_event_builder().sign_with_keys(&keys()).unwrap();
428 let tcat: Vec<&str> = event
429 .tags
430 .find_letter(Alphabet::I)
431 .filter_map(Tag::content)
432 .filter(|v| v.starts_with(TCAT_PREFIX))
433 .collect();
434 assert_eq!(tcat, ["tcat:video,movie,4k"]);
435 }
436
437 #[test]
438 fn parses_multiple_tcat_tags_flattened() {
439 let event = EventBuilder::new(Kind::TORRENT, "body")
442 .tags(Tags::from_vec(vec![
443 Tag::title("T"),
444 Tag::with(
445 &info_hash_kind(),
446 ["0123456789abcdef0123456789abcdef01234567"],
447 ),
448 Tag::i("tcat:video"),
449 Tag::i("tcat:movie"),
450 ]))
451 .sign_with_keys(&keys())
452 .unwrap();
453 let parsed = Torrent::from_event(&event).unwrap();
454 assert_eq!(parsed.categories, ["video", "movie"]);
455 }
456
457 #[test]
458 fn wrong_kind_is_rejected() {
459 let event = EventBuilder::text_note("nope")
460 .sign_with_keys(&keys())
461 .unwrap();
462 let err = Torrent::from_event(&event).unwrap_err();
463 assert!(matches!(
464 err,
465 TorrentError::UnexpectedKind {
466 expected: 2_003,
467 got: 1
468 }
469 ));
470 }
471
472 #[test]
473 fn missing_title_is_rejected() {
474 let event = EventBuilder::new(Kind::TORRENT, "body")
475 .tag(Tag::with(
476 &info_hash_kind(),
477 ["0123456789abcdef0123456789abcdef01234567"],
478 ))
479 .sign_with_keys(&keys())
480 .unwrap();
481 assert!(matches!(
482 Torrent::from_event(&event).unwrap_err(),
483 TorrentError::MissingTitle
484 ));
485 }
486
487 #[test]
488 fn missing_info_hash_is_rejected() {
489 let event = EventBuilder::new(Kind::TORRENT, "body")
490 .tag(Tag::title("T"))
491 .sign_with_keys(&keys())
492 .unwrap();
493 assert!(matches!(
494 Torrent::from_event(&event).unwrap_err(),
495 TorrentError::MissingInfoHash
496 ));
497 }
498
499 #[test]
500 fn invalid_file_size_is_rejected() {
501 let event = EventBuilder::new(Kind::TORRENT, "body")
502 .tags(Tags::from_vec(vec![
503 Tag::title("T"),
504 Tag::with(
505 &info_hash_kind(),
506 ["0123456789abcdef0123456789abcdef01234567"],
507 ),
508 Tag::with(&file_kind(), ["info/a.mkv", "not-a-number"]),
509 ]))
510 .sign_with_keys(&keys())
511 .unwrap();
512 assert!(matches!(
513 Torrent::from_event(&event).unwrap_err(),
514 TorrentError::InvalidFileSize
515 ));
516 }
517
518 #[test]
519 fn torrent_comment_follows_nip10() {
520 let torrent_event = sample().to_event_builder().sign_with_keys(&keys()).unwrap();
521 let comment = EventBuilder::torrent_comment("nice release", &torrent_event)
522 .sign_with_keys(&keys())
523 .unwrap();
524 assert_eq!(comment.kind, Kind::TORRENT_COMMENT);
525
526 let e_tag = comment.tags.find_letter(Alphabet::E).next().unwrap();
527 assert_eq!(e_tag.get(1), Some(torrent_event.id.to_hex().as_str()));
528 assert_eq!(e_tag.get(3), Some("root"));
529
530 let p_tag = comment.tags.find_letter(Alphabet::P).next().unwrap();
531 assert_eq!(
532 p_tag.content(),
533 Some(torrent_event.pubkey.to_hex().as_str())
534 );
535 }
536}