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
540 changes: 305 additions & 235 deletions Cargo.lock

Large diffs are not rendered by default.

9 changes: 5 additions & 4 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ path = "src/main.rs"
askama = "0.14.0"
# askama_web::WebTemplate implements axum::IntoResponse
askama_web = { version = "0.14.6", features = ["axum-0.8"] }
axum = { version = "0.8.4", features = ["macros"] }
axum = { version = "0.8.4", features = ["macros","multipart"] }
axum-extra = { version = "0.12.1", features = ["cookie"] }
# UTF-8 paths for easier String/PathBuf interop
camino = { version = "1.1.12", features = ["serde1"] }
Expand All @@ -36,13 +36,14 @@ env_logger = "0.11.8"
# 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 = { git = "https://github.com/angrynode/hightorrent_api", branch = "feat-sea-orm", features = [ "sea_orm" ]}
itertools = "0.14.0"
# hightorrent_api = "0.2"
log = "0.4.27"
# SQLite ORM
sea-orm = { version = "2.0.0-rc.21", features = [ "runtime-tokio", "debug-print", "sqlx-sqlite"] }
sea-orm = { version = "=2.0.0-rc.28", features = [ "runtime-tokio", "debug-print", "sqlx-sqlite"] }
# SQLite migrations
sea-orm-migration = { version = "2.0.0-rc.21" }
sea-orm-migration = { version = "=2.0.0-rc.28" }
# Serialization/deserialization, for example in path extractors
serde = { version = "1.0.219", features = ["derive", "rc"] }
# (De)serialization for operations log
Expand Down
19 changes: 18 additions & 1 deletion src/database/category.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ pub struct Model {
pub path: NormalizedPathAbsolute,
#[sea_orm(has_many)]
pub content_folders: HasMany<super::content_folder::Entity>,
#[sea_orm(has_many)]
pub magnets: HasMany<super::magnet::Entity>,
}

#[async_trait::async_trait]
Expand All @@ -47,6 +49,8 @@ pub enum CategoryError {
DB { source: sea_orm::DbErr },
#[snafu(display("The category (ID: {id}) does not exist"))]
IDNotFound { id: i32 },
#[snafu(display("The category id is invalid: {id}"))]
IDInvalid { id: String },
#[snafu(display("The category (Name: {name}) does not exist"))]
NameNotFound { name: String },
#[snafu(display("Failed to save the operation log"))]
Expand Down Expand Up @@ -76,7 +80,7 @@ impl CategoryOperator {

/// Find one category by ID
///
/// Should not fail, unless SQLite was corrupted for some reason.
/// Fails if the requested category ID does not exist.
pub async fn find_by_id(&self, id: i32) -> Result<Model, CategoryError> {
let category = Entity::find_by_id(id)
.one(&self.state.database)
Expand All @@ -89,6 +93,19 @@ impl CategoryOperator {
}
}

/// Find one category by stringy ID
///
/// Fails if:
///
/// - the requested ID does not exist
/// - the requested ID could not be parsed
pub async fn find_by_id_str(&self, id: &str) -> Result<Model, CategoryError> {
let id: i32 = id
.parse()
.map_err(|_e| CategoryError::IDInvalid { id: id.to_string() })?;
self.find_by_id(id).await
}

/// Find one category by Name
///
/// Should not fail, unless SQLite was corrupted for some reason.
Expand Down
17 changes: 17 additions & 0 deletions src/database/content_folder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,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 magnets: HasMany<super::magnet::Entity>,
}

#[async_trait::async_trait]
Expand All @@ -44,6 +46,8 @@ pub enum ContentFolderError {
PathTaken { path: String },
#[snafu(display("The Content Folder (Path: {path}) does not exist"))]
NotFound { path: String },
#[snafu(display("The content folder id is invalid: {id}"))]
IDInvalid { id: String },
#[snafu(display("Database error"))]
DB { source: sea_orm::DbErr },
#[snafu(display("Failed to save the operation log"))]
Expand Down Expand Up @@ -109,6 +113,19 @@ impl ContentFolderOperator {
}
}

/// Find one category by stringy ID
///
/// Fails if:
///
/// - the requested ID does not exist
/// - the requested ID could not be parsed
pub async fn find_by_id_str(&self, id: &str) -> Result<Model, ContentFolderError> {
let id: i32 = id
.parse()
.map_err(|_e| ContentFolderError::IDInvalid { id: id.to_string() })?;
self.find_by_id(id).await
}

/// Create a new content folder
///
/// Fails if:
Expand Down
206 changes: 206 additions & 0 deletions src/database/magnet.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
use chrono::Utc;
use hightorrent_api::hightorrent::{MagnetLink, MagnetLinkError, TorrentID};
use sea_orm::entity::prelude::*;
use sea_orm::*;
use snafu::prelude::*;

use crate::database::operation::*;
use crate::database::{category, content_folder, operator::DatabaseOperator};
use crate::routes::magnet::MagnetForm;
use crate::routes::magnet::ValidatedMagnetForm;
use crate::state::logger::LoggerError;

/// 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 = "magnet")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
pub torrent_id: TorrentID,
pub link: MagnetLink,
pub name: String,
pub resolved: bool,
pub content_folder_id: Option<i32>,
#[sea_orm(belongs_to, from = "content_folder_id", to = "id")]
pub content_folder: HasOne<content_folder::Entity>,
pub category_id: i32,
#[sea_orm(belongs_to, from = "category_id", to = "id")]
pub category: HasOne<category::Entity>,
}

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

#[derive(Debug, Snafu)]
#[snafu(visibility(pub))]
pub enum MagnetError {
#[snafu(display("The magnet is invalid"))]
InvalidMagnet { source: MagnetLinkError },
#[snafu(display("Database error"))]
DB { source: sea_orm::DbErr },
#[snafu(display("Error with the requested content folder"))]
ContentFolder {
source: content_folder::ContentFolderError,
},
#[snafu(display("Error with the requested category"))]
Category { source: category::CategoryError },
#[snafu(display("The magnet (ID: {id}) does not exist"))]
NotFound { id: i32 },
#[snafu(display("The magnet (TorrentID: {id}) does not exist"))]
NotFoundTorrentID { id: TorrentID },
#[snafu(display("Failed to save the operation log"))]
Logger { source: LoggerError },
}

#[derive(Clone, Debug)]
pub struct MagnetOperator {
pub db: DatabaseOperator,
}

impl MagnetOperator {
/// List magnets
///
/// Should not fail, unless SQLite was corrupted for some reason.
pub async fn list(&self) -> Result<Vec<Model>, MagnetError> {
Entity::find()
.all(&self.db.state.database)
.await
.context(DBSnafu)
}

/// List unresolved magnet
///
/// Should not fail, unless SQLite was corrupted for some reason.
pub async fn list_resolved(&self) -> Result<Vec<Model>, MagnetError> {
Entity::find()
.filter(Column::Resolved.eq(true))
.all(&self.db.state.database)
.await
.context(DBSnafu)
}

pub async fn get(&self, id: i32) -> Result<Model, MagnetError> {
let db = &self.db.state.database;

Entity::find_by_id(id)
.one(db)
.await
.context(DBSnafu)?
.ok_or(MagnetError::NotFound { id })
}

pub async fn get_by_torrent_id(&self, id: &TorrentID) -> Result<Model, MagnetError> {
let db = &self.db.state.database;

Entity::find()
.filter(Column::TorrentId.eq(id.clone()))
.one(db)
.await
.context(DBSnafu)?
.ok_or(MagnetError::NotFoundTorrentID { id: id.clone() })
}

/// Delete an uploaded magnet
pub async fn delete(&self, id: i32) -> Result<String, MagnetError> {
let db = &self.db.state.database;

let uploaded_magnet = Entity::find_by_id(id)
.one(db)
.await
.context(DBSnafu)?
.ok_or(MagnetError::NotFound { id })?;

let clone: Model = uploaded_magnet.clone();
uploaded_magnet.delete(db).await.context(DBSnafu)?;

let operation_log = OperationLog {
user: self.db.user.clone(),
date: Utc::now(),
table: Table::Magnet,
operation: OperationType::Delete,
operation_id: OperationId {
object_id: clone.id,
name: clone.name.to_owned(),
},
operation_form: None,
};

self.db
.state
.logger
.write(operation_log)
.await
.context(LoggerSnafu)?;

Ok(clone.name)
}

/// Create a new uploaded magnet
///
/// Fails if:
///
/// - the magnet is invalid
/// - the requested content folder does not exist
pub async fn create(&self, form: MagnetForm) -> Result<Model, MagnetError> {
let validated_form = ValidatedMagnetForm::from_form(&form, &self.db).await?;
let ValidatedMagnetForm {
magnet,
category,
content_folder,
} = validated_form;

// Check duplicates
let list = self.list().await?;

if list.iter().any(|x| x.torrent_id == magnet.id()) {
// The magnet is already known
return self.get_by_torrent_id(&magnet.id()).await;
}

let mut model = ActiveModel {
torrent_id: Set(magnet.id()),
link: Set(magnet.clone()),
name: Set(magnet.name().to_string()),
// TODO: check if we already have the torrent in which case it's already resolved!
resolved: Set(false),
category_id: Set(category.id),
..Default::default()
};

if let Some(content_folder) = content_folder {
model.content_folder_id = Set(Some(content_folder.id));
}

let model = model
.save(&self.db.state.database)
.await
.context(DBSnafu)?
.try_into_model()
.unwrap();

let operation_log = OperationLog {
user: self.db.user.clone(),
date: Utc::now(),
table: Table::Magnet,
operation: OperationType::Create,
operation_id: OperationId {
object_id: model.id.to_owned(),
name: model.name.to_string(),
},
operation_form: Some(Operation::Magnet(form)),
};

self.db
.state
.logger
.write(operation_log)
.await
.context(LoggerSnafu)?;

Ok(model)
}
}
1 change: 1 addition & 0 deletions src/database/mod.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
// sea_orm example: https://github.com/SeaQL/sea-orm/blob/master/examples/axum_example/
pub mod category;
pub mod content_folder;
pub mod magnet;
pub mod operation;
pub mod operator;
3 changes: 3 additions & 0 deletions src/database/operation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ use serde::{Deserialize, Serialize};
use crate::extractors::user::User;
use crate::routes::category::CategoryForm;
use crate::routes::content_folder::ContentFolderForm;
use crate::routes::magnet::MagnetForm;

/// Type of operation applied to the database.
#[derive(Clone, Debug, Display, Serialize, Deserialize)]
Expand All @@ -24,6 +25,7 @@ pub struct OperationId {
pub enum Table {
Category,
ContentFolder,
Magnet,
}

/// Operation applied to the database.
Expand All @@ -34,6 +36,7 @@ pub enum Table {
pub enum Operation {
Category(CategoryForm),
ContentFolder(ContentFolderForm),
Magnet(MagnetForm),
}

impl std::fmt::Display for Operation {
Expand Down
17 changes: 16 additions & 1 deletion src/database/operator.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,18 @@
use crate::database::{category::CategoryOperator, content_folder::ContentFolderOperator};
use crate::database::{
category::CategoryOperator, content_folder::ContentFolderOperator, magnet::MagnetOperator,
};
use crate::extractors::user::User;
use crate::state::AppState;

// TODO: we may want to have a ReadDatabaseOperator which does not have a user,
// but implements helper methods to access the tables. Something like
// DatabaseOperator is Deref<ReadDatabaseOperator> then have a
// ReadCategoryOperator, ReadContentFolderOperator, etc...
//
// Alternative approach, in each table operator, store a clone
// of the entire DatabaseOperator so we can switch table easily.
// We could play with lifetimes to make the clone cheaper, but
// that's not really a priority right now.
#[derive(Clone, Debug)]
pub struct DatabaseOperator {
pub state: AppState,
Expand All @@ -26,4 +37,8 @@ impl DatabaseOperator {
user: self.user.clone(),
}
}

pub fn magnet(&self) -> MagnetOperator {
MagnetOperator { db: self.clone() }
}
}
Loading