Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## UNRELEASED (YYYY-MM-DD)

### Added

- the `sea_orm` crate feature allows to use `MagnetLink`, `TorrentFile` and `TorrentID`
in sea_orm models
- `TorrentFile`, `DecodedTorrent` and `DecodedInfo` implement `PartialEq`
- `MagnetFile` implements `FromStr`, `TryFrom<String>`, and `Into<String>`
- `MagnetFile` supports serde de/serialization

## Version 0.4.0 (2025-11-10)

This release is focused on stricter parsing of torrents and magnets, and
Expand Down
13 changes: 13 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -26,13 +26,20 @@ sha256 = "1.5"
rustc-hex = "2.1"
serde = { version = "1", features = [ "derive" ] }
fluent-uri = { version = "0.4", features = [ "serde" ] }
# For Sea-ORM integration
sea-orm = { version = "2.0.0-rc.28", optional = true }
sea-orm-migration = { version = "2.0.0-rc.28", optional = true }
async-tempfile = { version = "0.7", optional = true }
tokio = { version = "1", optional = true }

[dev-dependencies]
serde_json = "1"

[features]
magnet_force_name = []
unknown_tracker_scheme = []
sea_orm = [ "dep:sea-orm" ]
test_sea_orm = [ "dep:sea-orm", "sea-orm/sqlx-sqlite", "dep:tokio", "tokio/macros", "tokio/rt", "dep:async-tempfile", "dep:sea-orm-migration", "sea-orm-migration/runtime-tokio" ]

[[test]]
name = "magnet_force_name"
Expand All @@ -45,3 +52,9 @@ name = "unknown_tracker_scheme"
path = "tests/unknown_tracker_scheme.rs"
required-features = [ "unknown_tracker_scheme" ]
test = true

[[test]]
name = "sea_orm"
path = "tests/sea_orm.rs"
required-features = [ "sea_orm", "test_sea_orm" ]
test = true
1 change: 1 addition & 0 deletions src/id.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ use crate::{InfoHash, InfoHashError};
/// [`TorrentID::from_infohash`](crate::id::TorrentID::from_infohash) and
/// [`InfoHash::id`](crate::hash::InfoHash::id) methods.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[cfg_attr(feature = "sea_orm", derive(sea_orm::DeriveValueType))]
pub struct TorrentID(String);

impl TorrentID {
Expand Down
45 changes: 44 additions & 1 deletion src/magnet.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ use fluent_uri::{ParseError as UriParseError, Uri};

use crate::{InfoHash, InfoHashError, TorrentID, Tracker, TrackerError};

use std::str::FromStr;

/// Error occurred during parsing a [`MagnetLink`](crate::magnet::MagnetLink).
#[derive(Clone, Debug, PartialEq)]
pub enum MagnetLinkError {
Expand Down Expand Up @@ -118,7 +120,11 @@ impl std::error::Error for MagnetLinkError {
///
/// More information is specified in [BEP-0009](https://bittorrent.org/beps/bep_0009.html), and
/// even more appears in the wild, as explained [on Wikipedia](https://en.wikipedia.org/wiki/Magnet_URI_scheme).
#[derive(Clone, Debug)]
#[derive(Clone, Debug, Deserialize, Serialize)]
#[cfg_attr(feature = "sea_orm", derive(sea_orm::DeriveValueType))]
#[cfg_attr(feature = "sea_orm", sea_orm(value_type = "String"))]
#[serde(try_from = "String")]
#[serde(into = "String")]
pub struct MagnetLink {
/// Only mandatory field for magnet link parsing, unless the
/// `magnet_force_name` crate feature is enabled.
Expand Down Expand Up @@ -322,6 +328,34 @@ impl std::fmt::Display for MagnetLink {
}
}

impl PartialEq for MagnetLink {
fn eq(&self, other: &Self) -> bool {
self.query == other.query
}
}

impl FromStr for MagnetLink {
type Err = MagnetLinkError;

fn from_str(s: &str) -> Result<Self, Self::Err> {
Self::new(s)
}
}

impl TryFrom<String> for MagnetLink {
type Error = MagnetLinkError;

fn try_from(s: String) -> Result<Self, Self::Error> {
Self::new(&s)
}
}

impl From<MagnetLink> for String {
fn from(m: MagnetLink) -> Self {
m.to_string()
}
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down Expand Up @@ -561,4 +595,13 @@ mod tests {
.collect::<Vec<_>>();
assert!(found.is_empty());
}

#[test]
fn serialization_roundtrip() {
let magnet_url = std::fs::read_to_string("tests/bittorrent-v2-test.magnet").unwrap();
let magnet = MagnetLink::new(&magnet_url).unwrap();
let json_url = serde_json::to_string(&magnet_url).unwrap();
let deserialized_magnet: MagnetLink = serde_json::from_str(&json_url).unwrap();
assert_eq!(deserialized_magnet, magnet,);
}
}
89 changes: 73 additions & 16 deletions src/torrent_file.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
use bt_bencode::Value as BencodeValue;
use rustc_hex::ToHex;
#[cfg(feature = "sea_orm")]
use sea_orm::prelude::*;
use serde::{Deserialize, Serialize};
use sha1::{Digest, Sha1};

Expand Down Expand Up @@ -85,7 +87,7 @@ impl std::error::Error for TorrentFileError {
/// ```ignore
/// std::fs::write("export.torrent", &torrent.to_vec()).unwrap();
/// ```
#[derive(Clone, Debug, Serialize, Deserialize)]
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct TorrentFile {
pub hash: InfoHash,
pub name: String,
Expand All @@ -98,7 +100,7 @@ pub struct TorrentFile {
/// In its present form, DecodedTorrent mostly cares about the info dict, but preserves other fields
/// as [`BencodeValue`](bt_bencode::BencodeValue) in an `extra` mapping so you can implement
/// your own extra parsing.
#[derive(Clone, Debug, Serialize, Deserialize)]
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct DecodedTorrent {
/// Main tracker
#[serde(skip_serializing_if = "Option::is_none")]
Expand All @@ -123,20 +125,10 @@ pub struct DecodedTorrent {

impl DecodedTorrent {
pub fn files(&self) -> Result<Vec<TorrentContent>, TorrentFileError> {
if self.info.files.is_none() {
if self.info.file_tree.is_none() {
// V1 torrent with single file
Ok(vec![TorrentContent {
path: PathBuf::from(&self.info.name),
size: self.info.length.unwrap(),
}])
} else {
todo!("v2 torrent files");
}
} else {
if let Some(info_files) = &self.info.files {
// V1 torrent with multiple files
let mut files: Vec<TorrentContent> = vec![];
for file in self.info.files.as_ref().unwrap() {
for file in info_files {
// TODO: error
let f: UnsafeV1FileContent = bt_bencode::from_value(file.clone()).unwrap();
if let Some(parsed_file) = f.to_torrent_content()? {
Expand All @@ -146,8 +138,18 @@ impl DecodedTorrent {

// Sort files by alphabetical order
files.sort();
Ok(files)
return Ok(files);
}

if let Some(_info_file_tree) = &self.info.file_tree {
todo!("v2 torrent files");
}

// V1 torrent with single file
Ok(vec![TorrentContent {
path: PathBuf::from(&self.info.name),
size: self.info.length.unwrap(),
}])
}
}

Expand Down Expand Up @@ -219,7 +221,7 @@ impl UnsafeV1FileContent {
/// mapping so you can implement your own extra parsing.
// bt_bencode does not support serializing None options and empty HashMaps, so we skip
// serialization in those cases.
#[derive(Clone, Debug, Serialize, Deserialize)]
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct DecodedInfo {
#[serde(rename = "meta version")]
#[serde(skip_serializing_if = "Option::is_none")]
Expand Down Expand Up @@ -330,6 +332,61 @@ impl TorrentFile {
}
}

#[cfg(feature = "sea_orm")]
impl From<TorrentFile> for sea_orm::sea_query::Value {
fn from(t: TorrentFile) -> Self {
Value::Bytes(Some(t.to_vec()))
}
}

#[cfg(feature = "sea_orm")]
impl sea_orm::TryGetable for TorrentFile {
fn try_get_by<I: sea_orm::ColIdx>(
res: &sea_orm::QueryResult,
index: I,
) -> Result<Self, sea_orm::error::TryGetError> {
let val: Vec<u8> = res.try_get_by(index)?;
TorrentFile::from_slice(&val).map_err(|e| {
sea_orm::error::TryGetError::DbErr(sea_orm::DbErr::TryIntoErr {
from: "Bytes",
into: "TorrentFile",
source: std::sync::Arc::new(e),
})
})
}
}

#[cfg(feature = "sea_orm")]
impl sea_orm::sea_query::ValueType for TorrentFile {
fn try_from(v: sea_orm::Value) -> Result<Self, sea_orm::sea_query::ValueTypeErr> {
match v {
sea_orm::Value::Bytes(Some(s)) => {
TorrentFile::from_slice(&s).map_err(|_e| sea_orm::sea_query::ValueTypeErr)
}
_ => Err(sea_orm::sea_query::ValueTypeErr),
}
}

fn type_name() -> String {
"TorrentFile".to_string()
}

fn array_type() -> sea_orm::sea_query::ArrayType {
sea_orm::sea_query::ArrayType::Bytes
}

fn column_type() -> sea_orm::sea_query::ColumnType {
sea_orm::sea_query::ColumnType::VarBinary(StringLen::None)
}
}

#[cfg(feature = "sea_orm")]
impl sea_orm::sea_query::Nullable for TorrentFile {
fn null() -> sea_orm::sea_query::Value {
sea_orm::sea_query::Value::Bytes(None)
}
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down
Loading