From 24defbf601c6d08b457623dc2c0f05b638820de3 Mon Sep 17 00:00:00 2001 From: angrynode Date: Wed, 20 May 2026 18:19:17 +0200 Subject: [PATCH 1/3] feat: Start torrent upload --- Cargo.lock | 112 ++++++++- Cargo.toml | 3 +- src/database/category.rs | 4 +- src/database/content_folder.rs | 3 + src/database/mod.rs | 1 + src/database/operation.rs | 3 + src/database/operator.rs | 11 +- src/database/torrent.rs | 233 ++++++++++++++++++ src/lib.rs | 2 + .../m20251114_01_create_table_torrent.rs | 57 +++++ src/migration/mod.rs | 2 + src/routes/mod.rs | 1 + src/routes/torrent.rs | 88 +++++++ src/state/context.rs | 3 + src/state/error.rs | 16 ++ src/state/linker.rs | 73 ++++++ src/state/mod.rs | 1 + .../content_folders/dropdown_actions.html | 40 ++- templates/shared/alert_operation_status.html | 8 +- templates/torrent/list.html | 28 +++ 20 files changed, 679 insertions(+), 10 deletions(-) create mode 100644 src/database/torrent.rs create mode 100644 src/migration/m20251114_01_create_table_torrent.rs create mode 100644 src/routes/torrent.rs create mode 100644 src/state/linker.rs create mode 100644 templates/torrent/list.html diff --git a/Cargo.lock b/Cargo.lock index f3eaea2..b40fdf7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -113,6 +113,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + [[package]] name = "arrayvec" version = "0.7.6" @@ -438,6 +444,7 @@ dependencies = [ "matchit", "memchr", "mime", + "multer", "percent-encoding", "pin-project-lite", "serde_core", @@ -504,6 +511,36 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "axum_typed_multipart" +version = "0.16.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74c8b2ee396b35396ec27f5b9aa101f77000ba842dc82549a381b74c3ae2db7e" +dependencies = [ + "anyhow", + "async-trait", + "axum", + "axum_typed_multipart_macros", + "bytes", + "futures-core", + "futures-util", + "thiserror", +] + +[[package]] +name = "axum_typed_multipart_macros" +version = "0.16.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a27cefbd055910a29c4a3710016559cece5bdb4fb78ec055a1c2e9f8c61e3aa9" +dependencies = [ + "darling 0.23.0", + "heck 0.5.0", + "proc-macro-error2", + "quote", + "syn 2.0.117", + "ubyte", +] + [[package]] name = "base64" version = "0.22.1" @@ -965,6 +1002,16 @@ dependencies = [ "darling_macro 0.21.3", ] +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core 0.23.0", + "darling_macro 0.23.0", +] + [[package]] name = "darling_core" version = "0.20.11" @@ -992,6 +1039,19 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.117", +] + [[package]] name = "darling_macro" version = "0.20.11" @@ -1014,6 +1074,17 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core 0.23.0", + "quote", + "syn 2.0.117", +] + [[package]] name = "der" version = "0.7.10" @@ -1128,6 +1199,15 @@ dependencies = [ "serde", ] +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + [[package]] name = "env_filter" version = "1.0.0" @@ -1489,12 +1569,12 @@ checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" [[package]] name = "hightorrent" version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "224af163ca2cc8a7e931071877cd38dd8af1da9a4a3efd647b728364f4916339" +source = "git+https://github.com/angrynode/hightorrent?branch=feat-sea-orm#2fa0245e0fb913d9a5016da9026bf84d26babd23" dependencies = [ "bt_bencode", "fluent-uri", "rustc-hex", + "sea-orm", "serde", "sha1", "sha256", @@ -1503,7 +1583,7 @@ dependencies = [ [[package]] name = "hightorrent_api" version = "0.2.1" -source = "git+https://github.com/angrynode/hightorrent_api#2288d5325d5ad4130e80cb8f714a130c54a60397" +source = "git+https://github.com/angrynode/hightorrent_api?branch=feat-sea-orm#e66fe8c193689d7db2f2a3d1bea268ccc9904bef" dependencies = [ "async-trait", "hightorrent", @@ -1635,7 +1715,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.5.10", + "socket2 0.6.3", "tokio", "tower-service", "tracing", @@ -2137,6 +2217,23 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "multer" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b" +dependencies = [ + "bytes", + "encoding_rs", + "futures-util", + "http", + "httparse", + "memchr", + "mime", + "spin", + "version_check", +] + [[package]] name = "nix" version = "0.26.4" @@ -3908,6 +4005,7 @@ dependencies = [ "async-tempfile", "axum", "axum-extra", + "axum_typed_multipart", "camino", "chrono", "clap", @@ -4043,6 +4141,12 @@ version = "1.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" +[[package]] +name = "ubyte" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f720def6ce1ee2fc44d40ac9ed6d3a59c361c80a75a7aa8e75bb9baed31cf2ea" + [[package]] name = "unic-langid" version = "0.9.6" diff --git a/Cargo.toml b/Cargo.toml index 52d3b18..10b994b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,6 +17,7 @@ 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"] } +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 @@ -36,7 +37,7 @@ 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 = { git = "https://github.com/angrynode/hightorrent_api", branch = "feat-sea-orm", features = [ "sea_orm" ] } # hightorrent_api = "0.2" log = "0.4.29" # SQLite ORM diff --git a/src/database/category.rs b/src/database/category.rs index 274c918..fcd8e08 100644 --- a/src/database/category.rs +++ b/src/database/category.rs @@ -6,7 +6,7 @@ use snafu::prelude::*; use std::str::FromStr; use crate::database::operator::DatabaseOperator; -use crate::database::{content_folder, operation::*}; +use crate::database::{content_folder, operation::*, torrent}; use crate::extractors::normalized_path::*; use crate::extractors::user::User; use crate::routes::category::CategoryForm; @@ -29,6 +29,8 @@ pub struct Model { pub path: NormalizedPathAbsolute, #[sea_orm(has_many)] pub content_folders: HasMany, + #[sea_orm(has_many)] + pub torrents: HasMany, } #[async_trait::async_trait] diff --git a/src/database/content_folder.rs b/src/database/content_folder.rs index af0090d..78f7fae 100644 --- a/src/database/content_folder.rs +++ b/src/database/content_folder.rs @@ -9,6 +9,7 @@ use std::str::FromStr; use crate::database::category::{self, CategoryError}; use crate::database::operation::{Operation, OperationId, OperationLog, OperationType, Table}; use crate::database::operator::DatabaseOperator; +use crate::database::torrent; use crate::extractors::normalized_path::{NormalizedPathAbsolute, NormalizedPathComponent}; use crate::extractors::user::User; use crate::routes::content_folder::ContentFolderForm; @@ -43,6 +44,8 @@ pub struct Model { pub parent_id: Option, #[sea_orm(self_ref, relation_enum = "Parent", from = "parent_id", to = "id")] pub parent: HasOne, + #[sea_orm(has_many)] + pub torrents: HasMany, } #[async_trait::async_trait] diff --git a/src/database/mod.rs b/src/database/mod.rs index bf0b773..de38c78 100644 --- a/src/database/mod.rs +++ b/src/database/mod.rs @@ -3,3 +3,4 @@ pub mod category; pub mod content_folder; pub mod operation; pub mod operator; +pub mod torrent; diff --git a/src/database/operation.rs b/src/database/operation.rs index 666d8d1..4afb5fb 100644 --- a/src/database/operation.rs +++ b/src/database/operation.rs @@ -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::torrent::TorrentForm; /// Type of operation applied to the database. #[derive(Clone, Debug, Display, Serialize, Deserialize)] @@ -24,6 +25,7 @@ pub struct OperationId { pub enum Table { Category, ContentFolder, + Torrent, } /// Operation applied to the database. @@ -34,6 +36,7 @@ pub enum Table { pub enum Operation { Category(CategoryForm), ContentFolder(ContentFolderForm), + Torrent(TorrentForm), } impl std::fmt::Display for Operation { diff --git a/src/database/operator.rs b/src/database/operator.rs index 566aaf8..9c3f2d3 100644 --- a/src/database/operator.rs +++ b/src/database/operator.rs @@ -1,4 +1,6 @@ -use crate::database::{category::CategoryOperator, content_folder::ContentFolderOperator}; +use crate::database::{ + category::CategoryOperator, content_folder::ContentFolderOperator, torrent::TorrentOperator, +}; use crate::extractors::user::User; use crate::state::AppState; @@ -26,4 +28,11 @@ impl DatabaseOperator { user: self.user.clone(), } } + + pub fn torrent(&self) -> TorrentOperator { + TorrentOperator { + state: self.state.clone(), + user: self.user.clone(), + } + } } diff --git a/src/database/torrent.rs b/src/database/torrent.rs new file mode 100644 index 0000000..cf0fa93 --- /dev/null +++ b/src/database/torrent.rs @@ -0,0 +1,233 @@ +use chrono::Utc; +use hightorrent_api::hightorrent::{TorrentFile, TorrentFileError, TorrentID}; +use sea_orm::entity::prelude::*; +use sea_orm::*; +use snafu::prelude::*; + +use crate::database::operation::*; +use crate::database::operator::DatabaseOperator; +use crate::database::{category, content_folder}; +use crate::extractors::user::User; +use crate::routes::torrent::TorrentForm; +use crate::state::AppState; +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 = "torrent")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: i32, + pub torrent_id: TorrentID, + pub file: TorrentFile, + pub name: String, + pub category_id: i32, + #[sea_orm(belongs_to, from = "category_id", to = "id")] + pub category: HasOne, + pub content_folder_id: Option, + #[sea_orm(belongs_to, from = "content_folder_id", to = "id")] + pub content_folder: HasOne, +} + +#[async_trait::async_trait] +impl ActiveModelBehavior for ActiveModel {} + +#[derive(Debug, Snafu)] +#[snafu(visibility(pub))] +pub enum TorrentError { + #[snafu(display("The torrent is invalid"))] + InvalidFile { source: TorrentFileError }, + #[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 category not found"))] + NoSuchCategory { id: i32 }, + #[snafu(display("Requested content folder not found"))] + NoSuchContentFolder { id: i32 }, +} + +#[derive(Clone, Debug)] +pub struct TorrentOperator { + pub state: AppState, + pub user: Option, +} + +impl TorrentOperator { + pub fn db(&self) -> DatabaseOperator { + DatabaseOperator { + state: self.state.clone(), + user: self.user.clone(), + } + } + + /// List torrents with related category/content_folder + /// + /// Should not fail, unless SQLite was corrupted for some reason. + pub async fn list_with_related(&self) -> Result, TorrentError> { + // Entity::find() + // method not found in `SelectTwoMany` + // .find_with_related(category::Entity) + // .find_with_related(content_folder::Entity) + // the trait `sea_orm::EntityTrait` is not implemented for `(database::category::Entity, database::content_folder::Entity) + // .find_with_related((category::Entity, content_folder::Entity)) + Entity::load() + .with(category::Entity) + .with(content_folder::Entity) + .all(&self.state.database) + .await + .context(DBSnafu) + } + + /// List torrents + /// + /// Should not fail, unless SQLite was corrupted for some reason. + pub async fn list(&self) -> Result, TorrentError> { + Entity::find() + .all(&self.state.database) + .await + .context(DBSnafu) + } + + /// Count magnets + /// + /// Should not fail, unless SQLite was corrupted for some reason. + pub async fn count(&self) -> Result { + // TODO: there may be a faster sea_orm operation for this + Ok(self.list().await?.len()) + } + + pub async fn get(&self, id: i32) -> Result { + let db = &self.state.database; + + Entity::find_by_id(id) + .one(db) + .await + .context(DBSnafu)? + .ok_or(TorrentError::NotFound { id }) + } + + pub async fn get_by_torrent_id(&self, id: &TorrentID) -> Result { + let db = &self.state.database; + + Entity::find() + .filter(Column::TorrentId.eq(id.clone())) + .one(db) + .await + .context(DBSnafu)? + .ok_or(TorrentError::NotFoundTorrentID { id: id.clone() }) + } + + /// Delete an uploaded magnet + pub async fn delete(&self, id: i32) -> Result { + let db = &self.state.database; + + let uploaded_torrent = Entity::find_by_id(id) + .one(db) + .await + .context(DBSnafu)? + .ok_or(TorrentError::NotFound { id })?; + + let clone: Model = uploaded_torrent.clone(); + uploaded_torrent.delete(db).await.context(DBSnafu)?; + + let operation_log = OperationLog { + user: self.user.clone(), + date: Utc::now(), + table: Table::Torrent, + operation: OperationType::Delete, + operation_id: OperationId { + object_id: clone.id, + name: clone.name.to_owned(), + }, + operation_form: None, + }; + + self.state + .logger + .write(operation_log) + .await + .context(LoggerSnafu)?; + + Ok(clone.name) + } + + /// Create a new uploaded magnet + /// + /// Fails if: + /// + /// - the torrent file is invalid + pub async fn create(&self, f: &TorrentForm) -> Result { + let torrent = TorrentFile::from_slice(&f.file).context(InvalidFileSnafu)?; + + // Check duplicates + let list = self.list().await?; + + if list.iter().any(|x| x.torrent_id == torrent.id()) { + // The torrent is already known + return self.get_by_torrent_id(&torrent.id()).await; + } + + // Verify that the requested category/content_folder exist + let _category = self + .db() + .category() + .find_by_id(f.category_id) + .await + .map_err(|_e| TorrentError::NoSuchCategory { id: f.category_id })?; + + if let Some(content_folder_id) = f.content_folder_id { + let _content_folder = self + .db() + .content_folder() + .find_by_id(content_folder_id) + .await + .map_err(|_e| TorrentError::NoSuchContentFolder { + id: content_folder_id, + })?; + } + + let model = ActiveModel { + torrent_id: Set(torrent.id()), + file: Set(torrent.clone()), + name: Set(torrent.name().to_string()), + category_id: Set(f.category_id), + content_folder_id: Set(f.content_folder_id), + ..Default::default() + } + .save(&self.state.database) + .await + .context(DBSnafu)?; + + // Should not fail + let model = model.try_into_model().unwrap(); + + let operation_log = OperationLog { + user: self.user.clone(), + date: Utc::now(), + table: Table::Torrent, + operation: OperationType::Create, + operation_id: OperationId { + object_id: model.id.to_owned(), + name: model.name.to_string(), + }, + operation_form: Some(Operation::Torrent(f.clone())), + }; + + self.state + .logger + .write(operation_log) + .await + .context(LoggerSnafu)?; + + Ok(model) + } +} diff --git a/src/lib.rs b/src/lib.rs index 7f0c705..cb35dd5 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -37,6 +37,8 @@ pub fn router(state: state::AppState) -> Router { ) .route("/folders", get(routes::index::index)) .route("/logs", get(routes::logs::index)) + .route("/torrent/upload", post(routes::torrent::upload)) + .route("/torrent", get(routes::torrent::list)) // Register static assets routes .nest("/assets", static_router()) // Insert request timing diff --git a/src/migration/m20251114_01_create_table_torrent.rs b/src/migration/m20251114_01_create_table_torrent.rs new file mode 100644 index 0000000..856f239 --- /dev/null +++ b/src/migration/m20251114_01_create_table_torrent.rs @@ -0,0 +1,57 @@ +use sea_orm_migration::{prelude::*, schema::*}; + +use crate::migration::m20251110_01_create_table_category::Category; +use crate::migration::m20251113_203047_add_content_folder::ContentFolder; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .create_table( + Table::create() + .table(Torrent::Table) + .if_not_exists() + .col(pk_auto(Torrent::Id)) + .col(string(Torrent::TorrentID).unique_key()) + .col(string(Torrent::Name)) + .col(var_binary(Torrent::File, 0)) + .col(ColumnDef::new(Torrent::ContentFolderId).integer().null()) + .foreign_key( + ForeignKey::create() + .name("fk-magnet-content_folder_id") + .from(Torrent::Table, Torrent::ContentFolderId) + .to(ContentFolder::Table, ContentFolder::Id), + ) + .col(ColumnDef::new(Torrent::CategoryId).integer()) + .foreign_key( + ForeignKey::create() + .name("fk-magnet-category_id") + .from(Torrent::Table, Torrent::CategoryId) + .to(Category::Table, Category::Id), + ) + .to_owned(), + ) + .await + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table(Torrent::Table).to_owned()) + .await + } +} + +#[derive(DeriveIden)] +enum Torrent { + Table, + Id, + #[allow(clippy::enum_variant_names)] + TorrentID, + Name, + File, + ContentFolderId, + CategoryId, +} diff --git a/src/migration/mod.rs b/src/migration/mod.rs index ee2294a..4e71730 100644 --- a/src/migration/mod.rs +++ b/src/migration/mod.rs @@ -3,6 +3,7 @@ pub use sea_orm_migration::prelude::*; mod m20251110_01_create_table_category; mod m20251113_203047_add_content_folder; mod m20251113_203899_add_uniq_to_content_folder; +mod m20251114_01_create_table_torrent; pub struct Migrator; @@ -13,6 +14,7 @@ impl MigratorTrait for Migrator { Box::new(m20251110_01_create_table_category::Migration), Box::new(m20251113_203047_add_content_folder::Migration), Box::new(m20251113_203899_add_uniq_to_content_folder::Migration), + Box::new(m20251114_01_create_table_torrent::Migration), ] } } diff --git a/src/routes/mod.rs b/src/routes/mod.rs index 34f239e..8b946d0 100644 --- a/src/routes/mod.rs +++ b/src/routes/mod.rs @@ -3,3 +3,4 @@ pub mod content_folder; pub mod index; pub mod logs; pub mod progress; +pub mod torrent; diff --git a/src/routes/torrent.rs b/src/routes/torrent.rs new file mode 100644 index 0000000..6940afa --- /dev/null +++ b/src/routes/torrent.rs @@ -0,0 +1,88 @@ +use askama::Template; +use askama_web::WebTemplate; +use axum_extra::extract::CookieJar; +use axum_typed_multipart::{TryFromMultipart, TypedMultipart}; +use serde::{Deserialize, Serialize}; +use snafu::prelude::*; + +use crate::database::torrent; +use crate::state::flash_message::{FlashRedirect, StatusCookie}; +use crate::state::{AppStateContext, error::*}; + +/// POST form submitted to /torrent/upload. +/// +/// At this point, we don't want to do all the sanity checks, +/// simply ensure the data is well-formed to further process +/// the request. +/// +/// We don't want to fail if the torrent is invalid (according +/// to hightorrent), because we still want to retrieve the requested +/// category/content_folder to produce the error flash redirection. +#[derive(Clone, Debug, Serialize, Deserialize, TryFromMultipart)] +pub struct TorrentForm { + pub category_id: i32, + pub content_folder_id: Option, + #[serde(skip)] + // We don't want to archive every upload in the *logs* + // axum_typed_multipart doesn't work with Vec with non-UTF8 + // content, see https://github.com/murar8/axum_typed_multipart/issues/88 + pub file: axum::body::Bytes, +} + +pub async fn upload( + context: AppStateContext, + jar: CookieJar, + TypedMultipart(form): TypedMultipart, +) -> Result { + // TODO: proper error type + if let Err(e) = context + .db + .torrent() + .create(&form) + .await + .context(TorrentUploadSnafu) + { + // TODO: if we had loaded the category/content_folder from the URL + // here, we could perform this more easily… In the meantime, we have + // to check manually if we have a corresponding category/content_folder + // and redirect with a flash error. + // RE: That would actually be really handy here. In the meantime, + // we introduce a helper which has no good place so we put it in the AppState. + let uri = context + .linker + .try_folder_url(form.category_id, form.content_folder_id) + .await?; + let status = StatusCookie::error(jar, e.to_string_recurse()); + return Ok(status.redirect(&uri)); + } + + let status = StatusCookie::success( + jar, + "The torrent has been uploaded and is now awaiting confirmation".to_string(), + ); + Ok(status.redirect("/torrent")) +} + +#[derive(Template, WebTemplate)] +#[template(path = "torrent/list.html")] +pub struct TorrentListTemplate { + /// Global application state (errors/warnings) + pub state: AppStateContext, + /// Torrents stored in database + pub torrents: Vec, +} + +pub async fn list(context: AppStateContext) -> Result { + let torrents = context + .db + .torrent() + .list_with_related() + .await + .boxed() + .context(OtherSnafu)?; + + Ok(TorrentListTemplate { + state: context, + torrents, + }) +} diff --git a/src/state/context.rs b/src/state/context.rs index b964849..a3bddde 100644 --- a/src/state/context.rs +++ b/src/state/context.rs @@ -2,6 +2,7 @@ use axum::extract::{FromRequestParts, OptionalFromRequestParts}; use axum::http::request::Parts; use crate::database::operator::DatabaseOperator; +use crate::state::linker::Linker; use super::*; @@ -13,6 +14,7 @@ use super::*; pub struct AppStateContext { pub db: DatabaseOperator, pub free_space: FreeSpace, + pub linker: Linker, pub state: AppState, pub user: Option, } @@ -29,6 +31,7 @@ impl FromRequestParts for AppStateContext { Ok(Self { db: DatabaseOperator::new(state.clone(), user.clone()), free_space: state.free_space()?, + linker: Linker::new(state.clone()), state: state.clone(), user, }) diff --git a/src/state/error.rs b/src/state/error.rs index 5123667..7ca45db 100644 --- a/src/state/error.rs +++ b/src/state/error.rs @@ -38,9 +38,25 @@ pub enum AppStateError { IO { source: std::io::Error }, #[snafu(display("{reason}"))] Static { reason: &'static str }, + #[snafu(display("Torrent upload error"))] + TorrentUpload { + source: crate::database::torrent::TorrentError, + }, } impl AppStateError { + // TODO: this is obviously wrong we don't want to pass around a + // stringy representation like this. But it'll do the job for now. + pub fn to_string_recurse(&self) -> String { + let mut s = String::new(); + s.push_str(&self.to_string()); + for error in self.iter_chain().skip(1) { + s.push('\n'); + s.push_str(&error.to_string()); + } + s + } + pub fn inner_errors(&self) -> Vec> { let mut inner_errors = vec![]; for error in self.iter_chain().skip(1) { diff --git a/src/state/linker.rs b/src/state/linker.rs new file mode 100644 index 0000000..423ead1 --- /dev/null +++ b/src/state/linker.rs @@ -0,0 +1,73 @@ +use snafu::prelude::*; + +use crate::database::operator::DatabaseOperator; +use crate::database::{category, content_folder}; +use crate::state::AppState; +use crate::state::error::*; + +pub struct Linker { + pub state: AppState, +} + +impl Linker { + pub fn new(state: AppState) -> Self { + Self { state } + } + + pub fn db(&self) -> DatabaseOperator { + DatabaseOperator { + state: self.state.clone(), + user: None, + } + } + + /// Generate a link to a category / content-folder from typed models. + /// + /// We support any type that implements `Into` and Clone because + /// i couldn't find a way to go from torrent::ModelEx which has loaded + /// related ModelEx, to related Model. + pub fn folder_url, U: Clone + Into>( + &self, + category: &T, + content_folder: Option<&U>, + ) -> String { + let category: category::Model = category.clone().into(); + let content_folder: Option = + content_folder.map(|x| x.clone().into()); + + // TODO: baseurl + if let Some(content_folder) = content_folder { + format!("/folders/{}{}", category.name, content_folder.path) + } else { + format!("/folders/{}", category.name) + } + } + + /// Generate a link to a category / content-folder + /// from their IDs, which is a fallible operation. + pub async fn try_folder_url( + &self, + category: i32, + content_folder: Option, + ) -> Result { + let category = self + .db() + .category() + .find_by_id(category) + .await + .context(CategorySnafu)?; + + if let Some(content_folder) = content_folder { + let content_folder = self + .db() + .content_folder() + .find_by_id(content_folder) + .await + .context(ContentFolderSnafu)?; + + return Ok(self.folder_url(&category, Some(&content_folder))); + } + + Ok(self.folder_url(&category, None::<&content_folder::Model>)) + } +} diff --git a/src/state/mod.rs b/src/state/mod.rs index d5090b4..c3c6681 100644 --- a/src/state/mod.rs +++ b/src/state/mod.rs @@ -12,6 +12,7 @@ pub use context::AppStateContext; pub mod error; pub mod flash_message; pub mod free_space; +pub mod linker; pub mod logger; use error::*; diff --git a/templates/content_folders/dropdown_actions.html b/templates/content_folders/dropdown_actions.html index f7db052..2715386 100644 --- a/templates/content_folders/dropdown_actions.html +++ b/templates/content_folders/dropdown_actions.html @@ -4,7 +4,43 @@ + + diff --git a/templates/shared/alert_operation_status.html b/templates/shared/alert_operation_status.html index 16d8151..1574ca4 100644 --- a/templates/shared/alert_operation_status.html +++ b/templates/shared/alert_operation_status.html @@ -1,7 +1,13 @@ {% if let Some(flash) = flash %}
{% for message in flash.message.messages() %} -

{{ message }}

+ {% let mut lines = message.split("\n") %} +

+ {{ lines.next().unwrap() }} + {% for msg in lines %} +
-> {{ msg }} + {% endfor %} +

{% endfor %}
{% endif %} diff --git a/templates/torrent/list.html b/templates/torrent/list.html new file mode 100644 index 0000000..435b923 --- /dev/null +++ b/templates/torrent/list.html @@ -0,0 +1,28 @@ +{% extends "base.html" %} + +{% block main %} +
+

List of uploaded torrents not yet imported

+ +
+
+
+ + + + + + + + {% for torrent in torrents %} + + + + + {% endfor %} +
NameActions
{{ torrent.name }}Aller dans le dossier
+
+
+
+
+{% endblock main %} From 7536668785f8b3f31c13a2d2629efac123d13487 Mon Sep 17 00:00:00 2001 From: angrynode Date: Wed, 20 May 2026 19:24:30 +0200 Subject: [PATCH 2/3] feat: Display pending torrents in category/folders --- src/database/torrent.rs | 33 +++++++++++++++++++++++++ src/extractors/category_request.rs | 8 ++++++ src/extractors/folder_request.rs | 9 +++++++ src/routes/category.rs | 5 ++++ src/routes/content_folder.rs | 6 ++++- templates/layouts/file_system_base.html | 26 +++++++++++++++++++ 6 files changed, 86 insertions(+), 1 deletion(-) diff --git a/src/database/torrent.rs b/src/database/torrent.rs index cf0fa93..534a6df 100644 --- a/src/database/torrent.rs +++ b/src/database/torrent.rs @@ -62,6 +62,10 @@ pub struct TorrentOperator { } impl TorrentOperator { + pub fn new(state: AppState, user: Option) -> Self { + Self { state, user } + } + pub fn db(&self) -> DatabaseOperator { DatabaseOperator { state: self.state.clone(), @@ -97,6 +101,35 @@ impl TorrentOperator { .context(DBSnafu) } + /// List torrents in a specific category (without recursing) + /// + /// Should not fail, unless SQLite was corrupted for some reason. + pub async fn list_for_category(&self, category_id: i32) -> Result, TorrentError> { + // TODO: optimization + Ok(self + .list() + .await? + .into_iter() + .filter(|x| x.category_id == category_id && x.content_folder_id.is_none()) + .collect()) + } + + /// List torrents in a specific content_folder (without recursing) + /// + /// Should not fail, unless SQLite was corrupted for some reason. + pub async fn list_for_content_folder( + &self, + content_folder_id: i32, + ) -> Result, TorrentError> { + // TODO: optimization + Ok(self + .list() + .await? + .into_iter() + .filter(|x| x.content_folder_id == Some(content_folder_id)) + .collect()) + } + /// Count magnets /// /// Should not fail, unless SQLite was corrupted for some reason. diff --git a/src/extractors/category_request.rs b/src/extractors/category_request.rs index 238dbd7..dec5417 100644 --- a/src/extractors/category_request.rs +++ b/src/extractors/category_request.rs @@ -4,6 +4,7 @@ use snafu::prelude::*; use crate::database::category::{self, CategoryOperator}; use crate::database::content_folder::PathBreadcrumb; +use crate::database::torrent::{self, TorrentOperator}; use crate::filesystem::FileSystemEntry; use crate::state::{AppState, error::*}; @@ -34,6 +35,7 @@ pub struct CategoryRequest { pub category: category::Model, pub breadcrumbs: Vec, pub children: Vec, + pub torrents: Vec, } impl FromRequestParts for CategoryRequest { @@ -66,10 +68,16 @@ impl FromRequestParts for CategoryRequest { let breadcrumbs = PathBreadcrumb::for_filesystem_path(category.name.as_str()); + let torrents = TorrentOperator::new(app_state.clone(), None) + .list_for_category(category.id) + .await + .context(TorrentUploadSnafu)?; + Ok(Self { category, children, breadcrumbs, + torrents, }) } } diff --git a/src/extractors/folder_request.rs b/src/extractors/folder_request.rs index 315bce0..e29255c 100644 --- a/src/extractors/folder_request.rs +++ b/src/extractors/folder_request.rs @@ -4,6 +4,7 @@ use snafu::prelude::*; use crate::database::category::{self, CategoryOperator}; use crate::database::content_folder::{self, ContentFolderOperator, PathBreadcrumb}; +use crate::database::torrent::{self, TorrentOperator}; use crate::filesystem::FileSystemEntry; use crate::state::AppState; use crate::state::error::*; @@ -14,6 +15,7 @@ pub struct FolderRequest { pub folder: content_folder::Model, pub children: Vec, pub breadcrumbs: Vec, + pub torrents: Vec, } impl FromRequestParts for FolderRequest { @@ -34,6 +36,7 @@ impl FromRequestParts for FolderRequest { // Read-only operators: no need to extract the current user let category_operator = CategoryOperator::new(app_state.clone(), None); let content_folder_operator = ContentFolderOperator::new(app_state.clone(), None); + let torrent_operator = TorrentOperator::new(app_state.clone(), None); // get current content folders with Path let current_content_folder = content_folder_operator @@ -60,11 +63,17 @@ impl FromRequestParts for FolderRequest { category.name, current_content_folder.path )); + let torrents = torrent_operator + .list_for_content_folder(current_content_folder.id) + .await + .context(TorrentUploadSnafu)?; + Ok(Self { category, folder: current_content_folder, children, breadcrumbs, + torrents, }) } } diff --git a/src/routes/category.rs b/src/routes/category.rs index df874a9..6e95b84 100644 --- a/src/routes/category.rs +++ b/src/routes/category.rs @@ -7,6 +7,7 @@ use serde::{Deserialize, Serialize}; use crate::database::category; use crate::database::content_folder::PathBreadcrumb; +use crate::database::torrent; use crate::extractors::category_request::{CategoriesRequest, CategoryRequest}; use crate::filesystem::FileSystemEntry; use crate::routes::content_folder::ContentFolderForm; @@ -92,6 +93,8 @@ pub struct CategoryShowTemplate { pub flash: Option, /// Breadcrumbs navigation pub breadcrumbs: Vec, + /// Torrents in this category + pub torrents: Vec, } impl CategoryShowTemplate { @@ -100,6 +103,7 @@ impl CategoryShowTemplate { breadcrumbs, category, children, + torrents, } = category; Self { @@ -108,6 +112,7 @@ impl CategoryShowTemplate { children, flash: None, state: context, + torrents, } } } diff --git a/src/routes/content_folder.rs b/src/routes/content_folder.rs index 3ea2788..735d93f 100644 --- a/src/routes/content_folder.rs +++ b/src/routes/content_folder.rs @@ -5,7 +5,7 @@ use axum_extra::extract::CookieJar; use serde::{Deserialize, Serialize}; use crate::database::content_folder::PathBreadcrumb; -use crate::database::{category, content_folder}; +use crate::database::{category, content_folder, torrent}; use crate::extractors::folder_request::FolderRequest; use crate::filesystem::FileSystemEntry; use crate::state::AppStateContext; @@ -35,6 +35,8 @@ pub struct ContentFolderShowTemplate { pub breadcrumbs: Vec, /// Operation status for UI confirmation (Cookie) pub flash: Option, + /// Related torrents in this folder + pub torrents: Vec, } impl ContentFolderShowTemplate { @@ -44,6 +46,7 @@ impl ContentFolderShowTemplate { category, children, folder, + torrents, } = folder; Self { @@ -53,6 +56,7 @@ impl ContentFolderShowTemplate { flash: None, folder, state: context, + torrents, } } } diff --git a/templates/layouts/file_system_base.html b/templates/layouts/file_system_base.html index c141922..2861d2c 100644 --- a/templates/layouts/file_system_base.html +++ b/templates/layouts/file_system_base.html @@ -42,6 +42,32 @@

File System

+ {# torrents is not defined on the homepage (category list) #} + {% if torrents is defined && torrents.len() > 0 %} +
+
+
+
+

{{ torrents.len() }} torrents waiting for confirmation in this folder

+
+
+
    + {% for torrent in torrents %} +
  • + {{ torrent.name }} will create the following folders/files in this folder: + {# TODO: file tree in hightorrent #} +
      + {% for file in torrent.file.decoded.files().unwrap() %} +
    • ({{ file.size | filesizeformat }}) {{ file.path.to_str().unwrap() }}
    • + {% endfor %} +
    +
  • + {% endfor %} +
+
+
+ {% endif %} +
From 49f5d181a33d8ec6c6f1bac77c659aa55d8937c5 Mon Sep 17 00:00:00 2001 From: angrynode Date: Sun, 24 May 2026 17:50:32 +0200 Subject: [PATCH 3/3] feat: Allow moving torrents between folders --- Cargo.lock | 56 ++++++--------------- Cargo.toml | 1 + src/database/operation.rs | 5 ++ src/database/torrent.rs | 36 ++++++++++++++ src/extractors/mod.rs | 1 + src/extractors/moving.rs | 15 ++++++ src/routes/category.rs | 59 ++++++++++++++++++---- src/routes/content_folder.rs | 66 ++++++++++++++++++++++--- src/state/flash_message.rs | 17 +++++++ templates/layouts/file_system_base.html | 30 ++++++++++- 10 files changed, 227 insertions(+), 59 deletions(-) create mode 100644 src/extractors/moving.rs diff --git a/Cargo.lock b/Cargo.lock index b40fdf7..f8173b3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -650,6 +650,15 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] + [[package]] name = "bstr" version = "1.12.1" @@ -992,16 +1001,6 @@ dependencies = [ "darling_macro 0.20.11", ] -[[package]] -name = "darling" -version = "0.21.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" -dependencies = [ - "darling_core 0.21.3", - "darling_macro 0.21.3", -] - [[package]] name = "darling" version = "0.23.0" @@ -1025,20 +1024,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "darling_core" -version = "0.21.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4" -dependencies = [ - "fnv", - "ident_case", - "proc-macro2", - "quote", - "strsim", - "syn 2.0.117", -] - [[package]] name = "darling_core" version = "0.23.0" @@ -1063,17 +1048,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "darling_macro" -version = "0.21.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" -dependencies = [ - "darling_core 0.21.3", - "quote", - "syn 2.0.117", -] - [[package]] name = "darling_macro" version = "0.23.0" @@ -3230,11 +3204,12 @@ dependencies = [ [[package]] name = "serde_with" -version = "3.16.1" +version = "3.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fa237f2807440d238e0364a218270b98f767a00d3dada77b1c53ae88940e2e7" +checksum = "e72c1c2cb7b223fafb600a619537a871c2818583d619401b785e7c0b746ccde2" dependencies = [ "base64", + "bs58", "chrono", "hex", "indexmap 1.9.3", @@ -3249,11 +3224,11 @@ dependencies = [ [[package]] name = "serde_with_macros" -version = "3.16.1" +version = "3.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52a8e3ca0ca629121f70ab50f95249e5a6f925cc0f6ffe8256c45b728875706c" +checksum = "b90c488738ecb4fb0262f41f43bc40efc5868d9fb744319ddf5f5317f417bfac" dependencies = [ - "darling 0.21.3", + "darling 0.23.0", "proc-macro2", "quote", "syn 2.0.117", @@ -4018,6 +3993,7 @@ dependencies = [ "sea-orm-migration", "serde", "serde_json", + "serde_with", "snafu 0.9.0", "static-serve", "tokio", diff --git a/Cargo.toml b/Cargo.toml index 10b994b..dc76fe5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -48,6 +48,7 @@ sea-orm-migration = { version = "2.0.0-rc.38" } serde = { version = "1.0.228", features = ["derive", "rc"] } # (De)serialization for operations log serde_json = { version = "1" } +serde_with = "3.20.0" # Error declaration/context snafu = "0.9" # Serve static assets directly from the binary diff --git a/src/database/operation.rs b/src/database/operation.rs index 4afb5fb..5ca45f1 100644 --- a/src/database/operation.rs +++ b/src/database/operation.rs @@ -37,6 +37,11 @@ pub enum Operation { Category(CategoryForm), ContentFolder(ContentFolderForm), Torrent(TorrentForm), + MoveTorrent { + torrent: i32, + category: i32, + content_folder: Option, + }, } impl std::fmt::Display for Operation { diff --git a/src/database/torrent.rs b/src/database/torrent.rs index 534a6df..c4b672b 100644 --- a/src/database/torrent.rs +++ b/src/database/torrent.rs @@ -263,4 +263,40 @@ impl TorrentOperator { Ok(model) } + + pub async fn update_category_content_folder( + &self, + model: Model, + category: category::Model, + content_folder: Option, + ) -> Result { + let mut active: ActiveModel = model.clone().into(); + active.category_id = Set(category.id); + active.content_folder_id = Set(content_folder.as_ref().map(|x| x.id)); + active.save(&self.state.database).await.context(DBSnafu)?; + + let operation_log = OperationLog { + user: self.user.clone(), + date: Utc::now(), + table: Table::Torrent, + operation: OperationType::Update, + operation_id: OperationId { + object_id: model.id.to_owned(), + name: model.name.to_string(), + }, + operation_form: Some(Operation::MoveTorrent { + torrent: model.id, + category: category.id, + content_folder: content_folder.map(|x| x.id), + }), + }; + + self.state + .logger + .write(operation_log) + .await + .context(LoggerSnafu)?; + + Ok(model) + } } diff --git a/src/extractors/mod.rs b/src/extractors/mod.rs index a95eb77..591bc71 100644 --- a/src/extractors/mod.rs +++ b/src/extractors/mod.rs @@ -1,5 +1,6 @@ pub mod category_request; pub mod folder_request; +pub mod moving; pub mod normalized_path; pub mod torrent_list; pub mod user; diff --git a/src/extractors/moving.rs b/src/extractors/moving.rs new file mode 100644 index 0000000..9bdf390 --- /dev/null +++ b/src/extractors/moving.rs @@ -0,0 +1,15 @@ +use serde::Deserialize; +use serde_with::serde_as; + +/// A request to move a torrent around in the categories/folders. +/// +/// When validate is set, the requested folder is set to the database. +#[derive(Clone, Debug, Deserialize)] +#[serde_as] +pub struct MovingQuery { + #[serde(default)] + #[serde_as(as = "DeserializeFromStr")] + pub id: Option, + #[serde(default)] + pub validate: bool, +} diff --git a/src/routes/category.rs b/src/routes/category.rs index 6e95b84..f14bbda 100644 --- a/src/routes/category.rs +++ b/src/routes/category.rs @@ -1,21 +1,22 @@ use askama::Template; use askama_web::WebTemplate; use axum::Form; -use axum::extract::Path; +use axum::extract::{Path, Query}; +use axum::response::{IntoResponse, Response}; use axum_extra::extract::CookieJar; use serde::{Deserialize, Serialize}; +use snafu::prelude::*; use crate::database::category; use crate::database::content_folder::PathBreadcrumb; use crate::database::torrent; use crate::extractors::category_request::{CategoriesRequest, CategoryRequest}; +use crate::extractors::moving::MovingQuery; use crate::filesystem::FileSystemEntry; use crate::routes::content_folder::ContentFolderForm; use crate::routes::index::IndexTemplate; -use crate::state::AppStateContext; -use crate::state::flash_message::{ - FallibleTemplate, FlashRedirect, FlashTemplate, OperationStatus, StatusCookie, -}; +use crate::state::flash_message::{FallibleTemplate, FlashRedirect, OperationStatus, StatusCookie}; +use crate::state::{AppStateContext, error::*}; #[derive(Clone, Debug, Deserialize, Serialize)] pub struct CategoryForm { @@ -95,10 +96,16 @@ pub struct CategoryShowTemplate { pub breadcrumbs: Vec, /// Torrents in this category pub torrents: Vec, + /// If any, the current torrent being moved in the folder. + pub current_torrent: Option, } impl CategoryShowTemplate { - fn new(context: AppStateContext, category: CategoryRequest) -> Self { + fn new( + context: AppStateContext, + category: CategoryRequest, + current_torrent: Option, + ) -> Self { let CategoryRequest { breadcrumbs, category, @@ -113,6 +120,7 @@ impl CategoryShowTemplate { flash: None, state: context, torrents, + current_torrent, } } } @@ -127,8 +135,41 @@ pub async fn show( context: AppStateContext, category: CategoryRequest, status: StatusCookie, -) -> FlashTemplate { - status.with_template(CategoryShowTemplate::new(context, category)) + Query(moving): Query, +) -> Result { + if let Some(id) = moving.id { + // We are currently moving a torrent between folders + let torrent: torrent::Model = context + .db + .torrent() + .get(id) + .await + .context(TorrentUploadSnafu)?; + if moving.validate { + // Save to DB the new location of the torrent + let _torrent = context + .db + .torrent() + .update_category_content_folder(torrent.clone(), category.category.clone(), None) + .await + .context(TorrentUploadSnafu)?; + // Now we produce a redirection (to the same page) in order to refresh the list of torrents + // in this folder, which was already computed. + Ok(status + .with_success("Torrent successfully saved to this folder".to_string()) + // Need to remove the query params + .redirect("?") + .into_response()) + } else { + Ok(status + .with_template(CategoryShowTemplate::new(context, category, Some(torrent))) + .into_response()) + } + } else { + Ok(status + .with_template(CategoryShowTemplate::new(context, category, None)) + .into_response()) + } } pub async fn create_folder( @@ -152,7 +193,7 @@ pub async fn create_folder( } Err(error) => { let status = OperationStatus::error(error); - Err(status.with_template(CategoryShowTemplate::new(context, category))) + Err(status.with_template(CategoryShowTemplate::new(context, category, None))) } } } diff --git a/src/routes/content_folder.rs b/src/routes/content_folder.rs index 735d93f..12efd47 100644 --- a/src/routes/content_folder.rs +++ b/src/routes/content_folder.rs @@ -1,17 +1,19 @@ use askama::Template; use askama_web::WebTemplate; use axum::Form; +use axum::extract::Query; +use axum::response::{IntoResponse, Response}; use axum_extra::extract::CookieJar; use serde::{Deserialize, Serialize}; +use snafu::prelude::*; use crate::database::content_folder::PathBreadcrumb; use crate::database::{category, content_folder, torrent}; use crate::extractors::folder_request::FolderRequest; +use crate::extractors::moving::MovingQuery; use crate::filesystem::FileSystemEntry; -use crate::state::AppStateContext; -use crate::state::flash_message::{ - FallibleTemplate, FlashRedirect, FlashTemplate, OperationStatus, StatusCookie, -}; +use crate::state::flash_message::{FallibleTemplate, FlashRedirect, OperationStatus, StatusCookie}; +use crate::state::{AppStateContext, error::*}; #[derive(Clone, Debug, Deserialize, Serialize)] pub struct ContentFolderForm { @@ -37,10 +39,16 @@ pub struct ContentFolderShowTemplate { pub flash: Option, /// Related torrents in this folder pub torrents: Vec, + /// If any, the current torrent being moved in the folder. + pub current_torrent: Option, } impl ContentFolderShowTemplate { - fn new(context: AppStateContext, folder: FolderRequest) -> Self { + fn new( + context: AppStateContext, + folder: FolderRequest, + current_torrent: Option, + ) -> Self { let FolderRequest { breadcrumbs, category, @@ -57,6 +65,7 @@ impl ContentFolderShowTemplate { folder, state: context, torrents, + current_torrent, } } } @@ -71,8 +80,49 @@ pub async fn show( context: AppStateContext, folder: FolderRequest, status: StatusCookie, -) -> FlashTemplate { - status.with_template(ContentFolderShowTemplate::new(context, folder)) + Query(moving): Query, +) -> Result { + if let Some(id) = moving.id { + // We are currently moving a torrent between folders + let torrent: torrent::Model = context + .db + .torrent() + .get(id) + .await + .context(TorrentUploadSnafu)?; + if moving.validate { + // Save to DB the new location of the torrent + let _torrent = context + .db + .torrent() + .update_category_content_folder( + torrent.clone(), + folder.category.clone(), + Some(folder.folder.clone()), + ) + .await + .context(TorrentUploadSnafu)?; + // Now we produce a redirection (to the same page) in order to refresh the list of torrents + // in this folder, which was already computed. + Ok(status + .with_success("Torrent successfully saved to this folder".to_string()) + // Need to remove the query params + .redirect("?") + .into_response()) + } else { + Ok(status + .with_template(ContentFolderShowTemplate::new( + context, + folder, + Some(torrent), + )) + .into_response()) + } + } else { + Ok(status + .with_template(ContentFolderShowTemplate::new(context, folder, None)) + .into_response()) + } } pub async fn create_subfolder( @@ -96,7 +146,7 @@ pub async fn create_subfolder( } Err(error) => { let status = OperationStatus::error(error); - Err(status.with_template(ContentFolderShowTemplate::new(context, folder))) + Err(status.with_template(ContentFolderShowTemplate::new(context, folder, None))) } } } diff --git a/src/state/flash_message.rs b/src/state/flash_message.rs index 9e443b1..a25bab3 100644 --- a/src/state/flash_message.rs +++ b/src/state/flash_message.rs @@ -154,6 +154,14 @@ impl StatusCookie { (self.cookies, Redirect::to(url)) } + pub fn with_success(self, s: String) -> Self { + Self::success(self.cookies, s) + } + + pub fn with_error(self, s: String) -> Self { + Self::error(self.cookies, s) + } + pub fn with_template(self, mut template: T) -> (CookieJar, T) { let cookies = self.cookies.clone(); template.with_optional_flash(self.message.map(|m| m.into())); @@ -170,6 +178,15 @@ impl From for OperationStatus { } } +impl From for StatusMessage { + fn from(s: OperationStatus) -> Self { + Self { + success: s.success, + message: s.message.messages().join("\n"), + } + } +} + #[derive(Clone, Debug)] pub struct StatusMessage { success: bool, diff --git a/templates/layouts/file_system_base.html b/templates/layouts/file_system_base.html index 2861d2c..e3f9f6d 100644 --- a/templates/layouts/file_system_base.html +++ b/templates/layouts/file_system_base.html @@ -1,5 +1,11 @@ {% extends "base.html" %} +{%- macro query_current_torrent() -%} +{%- if current_torrent is defined -%} +{%- if let Some(current_torrent) = current_torrent -%}?id={{ current_torrent.id }}{%- endif -%} +{%- endif -%} +{%- endmacro query_current_torrent -%} + {% block main %}

File System

@@ -14,6 +20,22 @@

File System

{% endif %} + {% if current_torrent is defined %} + {% if let Some(current_torrent) = ¤t_torrent %} +
+ You are currently moving the torrent {{ current_torrent.name }} + + + Validate moving here + + + + Cancel + +
+ {% endif %} + {% endif %} +
@@ -88,7 +114,7 @@

{% block folder_title %}{% endblock folder_title %}

{% for child in children %}
  • - +
    {# TODO: col-9 was col-md-9 on content_folder/category. Is that relevant? #}