Skip to content
Merged
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
1,535 changes: 1,473 additions & 62 deletions Cargo.lock

Large diffs are not rendered by default.

9 changes: 7 additions & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ askama = "0.16"
askama_web = { version = "0.16", features = ["axum-0.8"] }
axum = { version = "0.8.9", features = ["macros"] }
axum-extra = { version = "0.12.6", features = ["cookie"] }
# For torrent uploads
axum_typed_multipart = { version = "0.16.5", default-features = false }
# UTF-8 paths for easier String/PathBuf interop
camino = { version = "1.2.2", features = ["serde1"] }
# Date/time management
Expand All @@ -36,9 +38,11 @@ env_logger = "0.11.10"
# Interactions with the torrent client
# Comment/uncomment below for development version
# hightorrent_api = { path = "../hightorrent_api" }
hightorrent_api = { git = "https://github.com/angrynode/hightorrent_api" }
# hightorrent_api = "0.2"
# hightorrent_api = { git = "https://github.com/angrynode/hightorrent_api", branch = "feat-sea-orm", features = [ "sea_orm" ] }
hightorrent_api = { version = "0.2.2", features = [ "sea_orm" ] }
log = "0.4.29"
# rqbit torrent client to resolve magnets
librqbit = { git = "https://github.com/ikatson/rqbit" }
# SQLite ORM
sea-orm = { version = "2.0.0-rc.38", features = [ "runtime-tokio", "debug-print", "sqlx-sqlite"] }
# SQLite migrations
Expand Down Expand Up @@ -70,6 +74,7 @@ toml = { version = "1", features = ["preserve_order"] }
uucore = { version = "0.8.0", features = ["fsext"] }
# Finding XDG standard directories (such as ~/.config/torrentmanager/config.toml)
xdg = "3.0.0"
serde_with = "3.20.0"

[dev-dependencies]
async-tempfile = "0.7"
Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,15 @@ TorrentManager is not feature-complete yet. This branch is the third iteration o

- first prototype: PHP interface + bash/python processing scripts, only used for uploading torrents to qBittorrent (no follow-up)
- second iteration: Rust/rocket + bash/python processing scripts, allows viewing torrents from qBittorrent and filtering stuck torrents
- third iteration (this branch): Rust/axum
- third iteration (this branch): Rust/axum + qBittorrent backend

In the future, TorrentManager will become federated and allow you to find content from your friends to help and distribute it. For example, subscribing to a hypothetical [media.ccc.de](https://media.ccc.de/) instance would help seeding their video files and automatically importing them into your local media library.

- [ ] Torrent backend integrations
- [x] qBittorrent v5.0.x/v5.1.x
- [ ] Transmission
- [ ] rqbit
- [ ] Torrent categories (video, iso, etc...) for placement in different folders
- [x] Torrent categories (video, iso, etc...) for placement in different folders
- [ ] Torrent meta files (eg. associated subtitles)
- [ ] Federation
- [ ] Following new imports on other instances (RSS/ActivityPub)
Expand Down Expand Up @@ -95,7 +95,7 @@ In the future, all route handlers will have a signature indicating whether they

# TODO

- Keep local database of known torrents
- [x] Keep local database of known torrents
- Import unmanaged torrents (unknown to the local database)
- Upload new torrents to the client
- Keep track of associated files:
Expand Down
11 changes: 11 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,10 @@ pub struct AppConfig {

#[serde(default = "AppConfig::default_log_path")]
pub log_path: Utf8PathBuf,

/// Where rqbit saves its internal state.
#[serde(default = "AppConfig::default_rqbit_path")]
pub rqbit_path: Utf8PathBuf,
}

#[derive(Clone, Debug, Deserialize, Serialize)]
Expand Down Expand Up @@ -127,6 +131,13 @@ impl AppConfig {
Self::config_dir().join("operations.log")
}

pub fn default_rqbit_path() -> Utf8PathBuf {
// TODO: it looks like rqbit actually saves stuff in
// ~/.cache/com.rqbit.dht/dht.json and nothing in this
// folder we provide.
Self::config_dir().join("rqbit")
}

pub async fn load_from_xdg() -> Result<Self, ConfigError> {
let config_dir = Self::config_dir();
create_dir_all(&config_dir)
Expand Down
5 changes: 5 additions & 0 deletions src/database/content_folder/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@ pub enum ContentFolderError {
Logger { source: LoggerError },
#[snafu(display("Failed to create the folder on disk"))]
IO { source: std::io::Error },
#[snafu(display("Failed to load the torrent {id} requested to be moved"))]
MovingTorrent {
id: i32,
source: crate::database::torrent::TorrentError,
},
}

impl From<ContentFolderError> for AppStateError {
Expand Down
25 changes: 25 additions & 0 deletions src/database/content_folder/folder_view.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
use snafu::prelude::*;

use crate::database::torrent;

use super::*;

/// A loaded folder, with all surrounding entities loaded as well:
Expand All @@ -11,6 +15,8 @@ pub struct FolderView {
pub ancestors: Vec<Model>,
pub children: Vec<Model>,
pub folder: Option<Model>,
pub torrents: Vec<torrent::Model>,
pub moving_torrent: Option<torrent::Model>,
}

impl FolderView {
Expand All @@ -27,6 +33,8 @@ impl FolderView {
ancestors: vec![],
folder: None,
children,
torrents: vec![],
moving_torrent: None,
})
}

Expand All @@ -39,10 +47,25 @@ impl FolderView {
pub async fn from_id(
operator: &ContentFolderOperator<'_>,
id: i32,
moving_id: Option<i32>,
) -> Result<Self, ContentFolderError> {
let list = operator.list().await?;

if let Some(folder) = list.iter().find(|x| x.id == id) {
let torrents = operator.torrent().list_for_folder(folder).await.unwrap();

let moving_torrent = if let Some(moving_id) = moving_id {
Some(
operator
.torrent()
.get(moving_id)
.await
.context(MovingTorrentSnafu { id: moving_id })?,
)
} else {
None
};

Ok(Self {
ancestors: folder
.ancestors_from_list(&list)
Expand All @@ -55,6 +78,8 @@ impl FolderView {
.cloned()
.collect(),
folder: Some(folder.clone()),
torrents,
moving_torrent,
})
} else {
Err(ContentFolderError::NotFound { id })
Expand Down
3 changes: 3 additions & 0 deletions src/database/content_folder/model.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
use camino::{Utf8Path, Utf8PathBuf};
use sea_orm::entity::prelude::*;

use crate::database::torrent;
use crate::extractors::normalized_path::NormalizedPathComponent;

/// A content folder to store associated files.
Expand All @@ -18,6 +19,8 @@ pub struct Model {
pub parent_id: Option<i32>,
#[sea_orm(self_ref, relation_enum = "Parent", from = "parent_id", to = "id")]
pub parent: HasOne<Entity>,
#[sea_orm(has_many)]
pub torrents: HasMany<torrent::Entity>,
}

#[async_trait::async_trait]
Expand Down
1 change: 1 addition & 0 deletions src/database/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@
pub mod content_folder;
pub mod operation;
pub mod operator;
pub mod torrent;
3 changes: 3 additions & 0 deletions src/database/operation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ use derive_more::Display;
use serde::{Deserialize, Serialize};

use crate::database::content_folder;
use crate::database::torrent;
use crate::extractors::user::User;

/// Type of operation applied to the database.
Expand All @@ -16,6 +17,7 @@ pub enum OperationType {
#[derive(Clone, Debug, Display, Serialize, Deserialize)]
pub enum Table {
ContentFolder,
Torrent,
}

/// Operation applied to the database.
Expand All @@ -25,6 +27,7 @@ pub enum Table {
#[serde(untagged)]
pub enum Operation {
ContentFolder(content_folder::ContentFolderOperation),
Torrent(torrent::TorrentOperation),
}

impl std::fmt::Display for Operation {
Expand Down
5 changes: 5 additions & 0 deletions src/database/operator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ use std::ops::Deref;

use crate::database::content_folder::ContentFolderOperator;
use crate::database::operation::{Operation, OperationLog, OperationType, Table};
use crate::database::torrent::TorrentOperator;
use crate::extractors::user::User;
use crate::state::AppState;
use crate::state::logger::LoggerError;
Expand Down Expand Up @@ -64,4 +65,8 @@ impl DatabaseOperator {
pub fn content_folder<'a>(&'a self) -> ContentFolderOperator<'a> {
ContentFolderOperator { db: self }
}

pub fn torrent<'a>(&'a self) -> TorrentOperator<'a> {
TorrentOperator { db: self }
}
}
25 changes: 25 additions & 0 deletions src/database/torrent/error.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
use hightorrent_api::hightorrent::{MagnetLinkError, TorrentFileError, TorrentID};
use snafu::prelude::*;

use crate::state::logger::LoggerError;

#[derive(Debug, Snafu)]
#[snafu(visibility(pub))]
pub enum TorrentError {
#[snafu(display("Submitted torrent file is invalid"))]
InvalidFile { source: TorrentFileError },
#[snafu(display("The magnet is invalid"))]
InvalidLink { source: MagnetLinkError },
#[snafu(display("Database error"))]
DB { source: sea_orm::DbErr },
#[snafu(display("The torrent (ID: {id}) does not exist"))]
NotFound { id: i32 },
#[snafu(display("The torrent (TorrentID: {id}) does not exist"))]
NotFoundTorrentID { id: TorrentID },
#[snafu(display("Failed to save the operation log"))]
Logger { source: LoggerError },
#[snafu(display("Requested content folder not found"))]
NoSuchContentFolder { id: i32 },
#[snafu(display("The torrent ID {torrent_id} is already imported"))]
Duplicate { torrent_id: TorrentID },
}
8 changes: 8 additions & 0 deletions src/database/torrent/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
mod error;
pub use error::*;
mod model;
pub use model::*;
mod operation;
pub use operation::*;
mod operator;
pub use operator::*;
49 changes: 49 additions & 0 deletions src/database/torrent/model.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
use hightorrent_api::hightorrent::{MagnetLink, TorrentFile, TorrentID};
use sea_orm::entity::prelude::*;

use crate::database::content_folder;

/// A category to store associated files.
///
/// Each category has a name and an associated path on disk, where
/// symlinks to the content will be created.
#[sea_orm::model]
#[derive(Clone, Debug, PartialEq, DeriveEntityModel)]
#[sea_orm(table_name = "torrent")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
pub torrent_id: TorrentID,
pub torrent_file: Option<TorrentFile>,
pub magnet_link: MagnetLink,
pub name: String,
pub content_folder_id: i32,
#[sea_orm(belongs_to, from = "content_folder_id", to = "id")]
pub content_folder: HasOne<content_folder::Entity>,
pub status: TorrentStatus,
}

#[async_trait::async_trait]
impl ActiveModelBehavior for ActiveModel {}

/// The status of a torrent during the import pipeline, from
/// first upload until files are imported.
#[derive(Clone, Debug, EnumIter, DeriveActiveEnum, PartialEq)]
#[sea_orm(rs_type = "i32", db_type = "Integer")]
pub enum TorrentStatus {
/// Magnet is resolving to a torrent
#[sea_orm(num_value = 0)]
Resolving,
/// Torrent is downloading data
#[sea_orm(num_value = 1)]
Downloading,
/// Torrent has finished downloading, awaiting validation to import files.
#[sea_orm(num_value = 2)]
Downloaded,
/// Torrent import has been validated, symlinks are being created.
#[sea_orm(num_value = 3)]
Validated,
/// Torrent is finished and imported.
#[sea_orm(num_value = 4)]
Imported,
}
33 changes: 33 additions & 0 deletions src/database/torrent/operation.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
use serde::{Deserialize, Serialize};

use crate::database::operation::Operation;

#[derive(Clone, Debug, Serialize, Deserialize)]
pub enum TorrentOperation {
ImportTorrent {
id: i32,
name: String,
folder: (i32, String),
},
ImportMagnet {
id: i32,
name: String,
folder: (i32, String),
},
ResolveMagnet {
id: i32,
name: String,
},
MoveTorrent {
id: i32,
name: String,
previous_folder: (i32, String),
new_folder: (i32, String),
},
}

impl From<TorrentOperation> for Operation {
fn from(o: TorrentOperation) -> Self {
Self::Torrent(o)
}
}
Loading
Loading