From cc7310c9ab0e2d683160ddc03c654109170838f2 Mon Sep 17 00:00:00 2001 From: angrynode Date: Sat, 15 Nov 2025 11:48:54 +0100 Subject: [PATCH 1/7] feat: Start supporting magnet upload --- Cargo.lock | 33 +++- Cargo.toml | 4 +- src/database/category.rs | 7 +- src/database/magnet.rs | 177 ++++++++++++++++++ src/database/mod.rs | 1 + src/database/operation.rs | 3 + src/database/operator.rs | 11 +- src/lib.rs | 5 +- .../m20251114_01_create_table_magnet.rs | 58 ++++++ src/migration/mod.rs | 2 + src/routes/index.rs | 8 + src/routes/magnet.rs | 128 +++++++++++++ src/routes/mod.rs | 1 + src/state/error.rs | 4 + templates/magnet/list.html | 33 ++++ templates/magnet/show.html | 5 + templates/magnet/upload.html | 70 +++++++ templates/menus/header.html | 16 +- templates/sources/magnet.html | 19 +- templates/sources/torrent.html | 20 +- 20 files changed, 580 insertions(+), 25 deletions(-) create mode 100644 src/database/magnet.rs create mode 100644 src/migration/m20251114_01_create_table_magnet.rs create mode 100644 src/routes/magnet.rs create mode 100644 templates/magnet/list.html create mode 100644 templates/magnet/show.html create mode 100644 templates/magnet/upload.html diff --git a/Cargo.lock b/Cargo.lock index ec4cbe6..70b1348 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -437,6 +437,7 @@ dependencies = [ "matchit", "memchr", "mime", + "multer", "percent-encoding", "pin-project-lite", "serde_core", @@ -1083,6 +1084,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" @@ -1444,12 +1454,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#9d0a856a934ebd049defe7bd82baad0187279596" dependencies = [ "bt_bencode", "fluent-uri", "rustc-hex", + "sea-orm", "serde", "sha1", "sha256", @@ -1458,7 +1468,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", @@ -2063,6 +2073,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" diff --git a/Cargo.toml b/Cargo.toml index 3624809..9ab8398 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,7 +15,7 @@ path = "src/main.rs" askama = "0.15" # askama_web::WebTemplate implements axum::IntoResponse askama_web = { version = "0.15", features = ["axum-0.8"] } -axum = { version = "0.8.8", features = ["macros"] } +axum = { version = "0.8.8", 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"] } @@ -36,7 +36,7 @@ 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" ]} # hightorrent_api = "0.2" log = "0.4.27" # SQLite ORM diff --git a/src/database/category.rs b/src/database/category.rs index 083618f..5d8a06c 100644 --- a/src/database/category.rs +++ b/src/database/category.rs @@ -4,8 +4,9 @@ use sea_orm::entity::prelude::*; use sea_orm::*; use snafu::prelude::*; +use crate::database::operation::*; use crate::database::operator::DatabaseOperator; -use crate::database::{content_folder, operation::*}; +use crate::database::{content_folder, magnet}; use crate::extractors::normalized_path::*; use crate::extractors::user::User; use crate::routes::category::CategoryForm; @@ -27,7 +28,9 @@ pub struct Model { #[sea_orm(unique)] pub path: NormalizedPathAbsolute, #[sea_orm(has_many)] - pub content_folders: HasMany, + pub content_folders: HasMany, + #[sea_orm(has_many)] + pub magnets: HasMany, } #[async_trait::async_trait] diff --git a/src/database/magnet.rs b/src/database/magnet.rs new file mode 100644 index 0000000..82190fc --- /dev/null +++ b/src/database/magnet.rs @@ -0,0 +1,177 @@ +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}; +use crate::extractors::user::User; +use crate::routes::magnet::MagnetForm; +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 = "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: i32, + #[sea_orm(belongs_to, from = "content_folder_id", to = "id")] + pub content_folder: HasOne, + pub category_id: i32, + #[sea_orm(belongs_to, from = "category_id", to = "id")] + pub category: HasOne, +} + +#[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("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 state: AppState, + pub user: Option, +} + +impl MagnetOperator { + /// List magnets + /// + /// Should not fail, unless SQLite was corrupted for some reason. + pub async fn list(&self) -> Result, MagnetError> { + Entity::find() + .all(&self.state.database) + .await + .context(DBSnafu) + } + + 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(MagnetError::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(MagnetError::NotFoundTorrentID { id: id.clone() }) + } + + /// Delete an uploaded magnet + pub async fn delete(&self, id: i32) -> Result { + let db = &self.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.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.state + .logger + .write(operation_log) + .await + .context(LoggerSnafu)?; + + Ok(clone.name) + } + + /// Create a new uploaded magnet + /// + /// Fails if: + /// + /// - the magnet is invalid + pub async fn create(&self, f: &MagnetForm) -> Result { + let magnet = MagnetLink::new(&f.magnet).context(InvalidMagnetSnafu)?; + + // 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 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), + ..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::Magnet, + operation: OperationType::Create, + operation_id: OperationId { + object_id: model.id.to_owned(), + name: model.name.to_string(), + }, + operation_form: Some(Operation::Magnet(f.clone())), + }; + + self.state + .logger + .write(operation_log) + .await + .context(LoggerSnafu)?; + + Ok(model) + } +} diff --git a/src/database/mod.rs b/src/database/mod.rs index bf0b773..70a5dca 100644 --- a/src/database/mod.rs +++ b/src/database/mod.rs @@ -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; diff --git a/src/database/operation.rs b/src/database/operation.rs index 492f8c4..c0558fd 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::magnet::MagnetForm; /// 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, + Magnet, } /// Operation applied to the database. @@ -34,6 +36,7 @@ pub enum Table { pub enum Operation { Category(CategoryForm), ContentFolder(ContentFolderForm), + Magnet(MagnetForm), } impl std::fmt::Display for Operation { diff --git a/src/database/operator.rs b/src/database/operator.rs index 566aaf8..d5ae109 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, magnet::MagnetOperator, +}; use crate::extractors::user::User; use crate::state::AppState; @@ -26,4 +28,11 @@ impl DatabaseOperator { user: self.user.clone(), } } + + pub fn magnet(&self) -> MagnetOperator { + MagnetOperator { + state: self.state.clone(), + user: self.user.clone(), + } + } } diff --git a/src/lib.rs b/src/lib.rs index ae60294..9dd41c6 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -21,7 +21,6 @@ pub fn router(state: state::AppState) -> Router { Router::new() // Register dynamic routes .route("/", get(routes::index::index)) - .route("/upload", get(routes::index::upload)) .route("/progress/{view_request}", get(routes::progress::progress)) .route("/categories", post(routes::category::create)) .route("/categories/new", get(routes::category::new)) @@ -34,6 +33,10 @@ pub fn router(state: state::AppState) -> Router { .route("/folders", get(routes::index::index)) .route("/folders", post(routes::content_folder::create)) .route("/logs", get(routes::logs::index)) + .route("/magnet/upload", post(routes::magnet::upload)) + .route("/magnet/upload", get(routes::magnet::get_upload)) + .route("/magnet", get(routes::magnet::list)) + .route("/magnet/{id}", get(routes::magnet::show)) // Register static assets routes .nest("/assets", static_router()) // Insert request timing diff --git a/src/migration/m20251114_01_create_table_magnet.rs b/src/migration/m20251114_01_create_table_magnet.rs new file mode 100644 index 0000000..fb982f6 --- /dev/null +++ b/src/migration/m20251114_01_create_table_magnet.rs @@ -0,0 +1,58 @@ +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(Magnet::Table) + .if_not_exists() + .col(pk_auto(Magnet::Id)) + .col(string(Magnet::TorrentID).unique_key()) + .col(string(Magnet::Name)) + .col(string(Magnet::Link)) + .col(boolean(Magnet::Resolved)) + .col(ColumnDef::new(Magnet::ContentFolderId).integer()) + .foreign_key( + ForeignKey::create() + .name("fk-magnet-content_folder_id") + .from(Magnet::Table, Magnet::ContentFolderId) + .to(ContentFolder::Table, ContentFolder::Id), + ) + .col(ColumnDef::new(Magnet::CategoryId).integer()) + .foreign_key( + ForeignKey::create() + .name("fk-magnet-category_id") + .from(Magnet::Table, Magnet::CategoryId) + .to(Category::Table, Category::Id), + ) + .to_owned(), + ) + .await + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table(Magnet::Table).to_owned()) + .await + } +} + +#[derive(DeriveIden)] +enum Magnet { + Table, + Id, + TorrentID, + Name, + Link, + Resolved, + ContentFolderId, + CategoryId, +} diff --git a/src/migration/mod.rs b/src/migration/mod.rs index ee2294a..93f4727 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_magnet; 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_magnet::Migration), ] } } diff --git a/src/routes/index.rs b/src/routes/index.rs index c4130f5..789a27c 100644 --- a/src/routes/index.rs +++ b/src/routes/index.rs @@ -5,6 +5,7 @@ use snafu::prelude::*; // TUTORIAL: https://github.com/SeaQL/sea-orm/blob/master/examples/axum_example/ use crate::filesystem::FileSystemEntry; +use crate::routes::magnet::MagnetForm; use crate::state::flash_message::{OperationStatus, get_cookie}; use crate::state::{AppStateContext, error::*}; @@ -26,6 +27,11 @@ pub struct UploadTemplate { pub state: AppStateContext, /// Categories pub categories: Vec, + // TODO: also support torrent upload + /// Magnet upload form + pub post: Option, + /// Error with submitted magnet + pub post_error: Option, } impl IndexTemplate { @@ -64,6 +70,8 @@ impl UploadTemplate { Ok(UploadTemplate { state: context, categories, + post: None, + post_error: None, }) } } diff --git a/src/routes/magnet.rs b/src/routes/magnet.rs new file mode 100644 index 0000000..c741e3c --- /dev/null +++ b/src/routes/magnet.rs @@ -0,0 +1,128 @@ +use askama::Template; +use askama_web::WebTemplate; +use axum::extract::{Form, Path}; +use axum::response::{IntoResponse, Response}; +use serde::{Deserialize, Serialize}; +use snafu::prelude::*; + +use crate::database::category; +use crate::database::magnet; +use crate::state::{AppStateContext, error::*}; + +/// Multipart form submitted to /magnet/upload: +/// +/// - magnet: the magnet link to upload +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct MagnetForm { + pub magnet: String, +} + +#[derive(Template, WebTemplate)] +#[template(path = "magnet/show.html")] +pub struct MagnetTemplate { + /// Global application state (errors/warnings) + pub state: AppStateContext, + /// Parsed magnet from form + pub magnet: magnet::Model, +} + +pub async fn show( + context: AppStateContext, + Path(id): Path, +) -> Result { + let magnet = context + .db + .magnet() + .get(id) + .await + .boxed() + .context(OtherSnafu)?; + + Ok(MagnetTemplate { + state: context, + magnet, + }) +} + +pub async fn upload( + context: AppStateContext, + Form(form): Form, +) -> Result { + // Parse magnet + match context + .db + .magnet() + .create(&form) + .await + .context(MagnetUploadSnafu) + { + Ok(magnet_model) => Ok(MagnetTemplate { + state: context, + magnet: magnet_model, + } + .into_response()), + Err(e) => Ok(UploadMagnetTemplate::new(context) + .await? + .with_errored_form(form, e) + .into_response()), + } +} + +#[derive(Template, WebTemplate)] +#[template(path = "magnet/list.html")] +pub struct MagnetListTemplate { + /// Global application state (errors/warnings) + pub state: AppStateContext, + /// Magnets stored in database + pub magnets: Vec, +} + +pub async fn list(context: AppStateContext) -> Result { + let magnets = context + .db + .magnet() + .list() + .await + .boxed() + .context(OtherSnafu)?; + + Ok(MagnetListTemplate { + state: context, + magnets, + }) +} + +#[derive(Template, WebTemplate)] +#[template(path = "magnet/upload.html")] +pub struct UploadMagnetTemplate { + /// Global application state (errors/warnings) + pub state: AppStateContext, + /// Magnet upload form + pub post: Option, + /// Error with submitted magnet + pub post_error: Option, + pub categories: Vec, +} + +pub async fn get_upload(context: AppStateContext) -> Result { + UploadMagnetTemplate::new(context).await +} + +impl UploadMagnetTemplate { + pub async fn new(context: AppStateContext) -> Result { + let categories = context.db.category().list().await.context(CategorySnafu)?; + + Ok(UploadMagnetTemplate { + state: context, + categories, + post: None, + post_error: None, + }) + } + + pub fn with_errored_form(mut self, form: MagnetForm, error: AppStateError) -> Self { + self.post = Some(form); + self.post_error = Some(error); + self + } +} diff --git a/src/routes/mod.rs b/src/routes/mod.rs index 34f239e..74f95ea 100644 --- a/src/routes/mod.rs +++ b/src/routes/mod.rs @@ -2,4 +2,5 @@ pub mod category; pub mod content_folder; pub mod index; pub mod logs; +pub mod magnet; pub mod progress; diff --git a/src/state/error.rs b/src/state/error.rs index a2a3876..c235adf 100644 --- a/src/state/error.rs +++ b/src/state/error.rs @@ -38,6 +38,10 @@ pub enum AppStateError { IO { source: std::io::Error }, #[snafu(display("{reason}"))] Static { reason: &'static str }, + #[snafu(display("Magnet upload error"))] + MagnetUpload { + source: crate::database::magnet::MagnetError, + }, } impl AppStateError { diff --git a/templates/magnet/list.html b/templates/magnet/list.html new file mode 100644 index 0000000..e3505c9 --- /dev/null +++ b/templates/magnet/list.html @@ -0,0 +1,33 @@ +{% extends "base.html" %} + +{% block main %} +
+

List of uploaded magnets not yet resolved

+
+ + + + + + + + + {% for magnet in magnets %} + + + + + + {% endfor %} +
ResolvedNameActions
{{ magnet.resolved }}{{ magnet.name }} + +
+
+{% endblock main %} diff --git a/templates/magnet/show.html b/templates/magnet/show.html new file mode 100644 index 0000000..887b7f4 --- /dev/null +++ b/templates/magnet/show.html @@ -0,0 +1,5 @@ +{% extends "base.html" %} + +{% block main %} +You successfully uploaded magnet {{ magnet.name }} +{% endblock main %} diff --git a/templates/magnet/upload.html b/templates/magnet/upload.html new file mode 100644 index 0000000..fbb8d49 --- /dev/null +++ b/templates/magnet/upload.html @@ -0,0 +1,70 @@ +{% extends "base.html" %} + +{% block main %} +
+

Download files

+ + {% if state.free_space.free_space_percent < 5 %} +
+ The disks are almost full : {{ state.free_space.free_space_gib }} GiB left +
+ {% endif %} + + {% if let Some(error) = post_error %} +
+
+ {{ error }} +
    + {% for inner_error in error.inner_errors() %} +
  • → {{ inner_error }}
  • + {% endfor %} +
+
+
+ {% endif %} + + +
+
+
+
+ +
Choose the type of content you want to download.
+ +
+
+
+ +
+
+ {% include "sources/magnet.html" %} +
+ + + {% if categories.len() == 0 %} + + + + {% endif %} +
+{% endblock %} diff --git a/templates/menus/header.html b/templates/menus/header.html index fb858ff..b8122a9 100755 --- a/templates/menus/header.html +++ b/templates/menus/header.html @@ -13,14 +13,26 @@ + + - + - Download new torrent + Add magnet
diff --git a/templates/sources/magnet.html b/templates/sources/magnet.html index 2d1485d..d67e980 100755 --- a/templates/sources/magnet.html +++ b/templates/sources/magnet.html @@ -4,13 +4,18 @@

-
-
- +
+
+
+ +
+
+ +
+ +
+
-
- -
-
+
diff --git a/templates/sources/torrent.html b/templates/sources/torrent.html index 41a951d..3ef6a9a 100755 --- a/templates/sources/torrent.html +++ b/templates/sources/torrent.html @@ -2,13 +2,19 @@

Torrent file

-
-
- +
+
+
+ +
+
+ +
+ {# TODO: Implement torrent upload form #} + +
+
-
- -
-
+
From f0f76ce00ec07cfb533ad3c4899f3047f361f5a7 Mon Sep 17 00:00:00 2001 From: gabatxo1312 Date: Mon, 16 Feb 2026 22:53:18 +0100 Subject: [PATCH 2/7] feat: Pending import button for resolved magnets --- src/database/magnet.rs | 11 ++++ src/lib.rs | 1 - src/routes/category.rs | 9 +-- src/routes/magnet.rs | 49 +++------------ src/state/context.rs | 11 +++- src/state/error.rs | 6 +- .../content_folders/dropdown_actions.html | 2 +- templates/magnet/list.html | 61 ++++++++++--------- templates/magnet/show.html | 5 -- templates/menus/header.html | 13 ++-- 10 files changed, 78 insertions(+), 90 deletions(-) delete mode 100644 templates/magnet/show.html diff --git a/src/database/magnet.rs b/src/database/magnet.rs index 82190fc..7b81bcd 100644 --- a/src/database/magnet.rs +++ b/src/database/magnet.rs @@ -68,6 +68,17 @@ impl MagnetOperator { .context(DBSnafu) } + /// List unresolved magnet + /// + /// Should not fail, unless SQLite was corrupted for some reason. + pub async fn list_resolved(&self) -> Result, MagnetError> { + Entity::find() + .filter(Column::Resolved.eq(true)) + .all(&self.state.database) + .await + .context(DBSnafu) + } + pub async fn get(&self, id: i32) -> Result { let db = &self.state.database; diff --git a/src/lib.rs b/src/lib.rs index 9dd41c6..2880220 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -36,7 +36,6 @@ pub fn router(state: state::AppState) -> Router { .route("/magnet/upload", post(routes::magnet::upload)) .route("/magnet/upload", get(routes::magnet::get_upload)) .route("/magnet", get(routes::magnet::list)) - .route("/magnet/{id}", get(routes::magnet::show)) // Register static assets routes .nest("/assets", static_router()) // Insert request timing diff --git a/src/routes/category.rs b/src/routes/category.rs index a2e1669..9d2e578 100644 --- a/src/routes/category.rs +++ b/src/routes/category.rs @@ -7,8 +7,7 @@ use axum_extra::extract::CookieJar; use serde::{Deserialize, Serialize}; use snafu::prelude::*; -use crate::database::category; -use crate::database::category::CategoryError; +use crate::database::category::{self, CategoryError}; use crate::database::content_folder::PathBreadcrumb; use crate::extractors::normalized_path::*; use crate::filesystem::FileSystemEntry; @@ -32,11 +31,9 @@ pub struct NewCategoryTemplate { pub category_form: Option, } -pub async fn new( - app_state_context: AppStateContext, -) -> Result { +pub async fn new(context: AppStateContext) -> Result { Ok(NewCategoryTemplate { - state: app_state_context, + state: context, category_form: None, error: None, }) diff --git a/src/routes/magnet.rs b/src/routes/magnet.rs index c741e3c..50d2bb1 100644 --- a/src/routes/magnet.rs +++ b/src/routes/magnet.rs @@ -1,12 +1,11 @@ use askama::Template; use askama_web::WebTemplate; -use axum::extract::{Form, Path}; -use axum::response::{IntoResponse, Response}; +use axum::extract::Form; +use axum::response::{IntoResponse, Redirect, Response}; use serde::{Deserialize, Serialize}; use snafu::prelude::*; -use crate::database::category; -use crate::database::magnet; +use crate::database::{category, magnet}; use crate::state::{AppStateContext, error::*}; /// Multipart form submitted to /magnet/upload: @@ -17,55 +16,25 @@ pub struct MagnetForm { pub magnet: String, } -#[derive(Template, WebTemplate)] -#[template(path = "magnet/show.html")] -pub struct MagnetTemplate { - /// Global application state (errors/warnings) - pub state: AppStateContext, - /// Parsed magnet from form - pub magnet: magnet::Model, -} - -pub async fn show( - context: AppStateContext, - Path(id): Path, -) -> Result { - let magnet = context - .db - .magnet() - .get(id) - .await - .boxed() - .context(OtherSnafu)?; - - Ok(MagnetTemplate { - state: context, - magnet, - }) -} - pub async fn upload( context: AppStateContext, Form(form): Form, ) -> Result { - // Parse magnet - match context + // TODO: proper error type + if let Err(e) = context .db .magnet() .create(&form) .await .context(MagnetUploadSnafu) { - Ok(magnet_model) => Ok(MagnetTemplate { - state: context, - magnet: magnet_model, - } - .into_response()), - Err(e) => Ok(UploadMagnetTemplate::new(context) + return Ok(UploadMagnetTemplate::new(context) .await? .with_errored_form(form, e) - .into_response()), + .into_response()); } + + Ok(Redirect::to("/magnet").into_response()) } #[derive(Template, WebTemplate)] diff --git a/src/state/context.rs b/src/state/context.rs index e075e9a..3bdf07f 100644 --- a/src/state/context.rs +++ b/src/state/context.rs @@ -14,6 +14,7 @@ pub struct AppStateContext { pub db: DatabaseOperator, pub errors: Vec, pub free_space: FreeSpace, + pub resolved_magnets_count: usize, pub state: AppState, pub user: Option, } @@ -26,11 +27,19 @@ impl FromRequestParts for AppStateContext { state: &AppState, ) -> Result { let user = User::from_request_parts(parts, state).await?; + let db = DatabaseOperator::new(state.clone(), user.clone()); + let resolved_magnets_count = db + .magnet() + .list_resolved() + .await + .context(MagnetUploadSnafu)? + .len(); Ok(Self { - db: DatabaseOperator::new(state.clone(), user.clone()), + db, errors: vec![], free_space: state.free_space()?, + resolved_magnets_count, state: state.clone(), user, }) diff --git a/src/state/error.rs b/src/state/error.rs index c235adf..41f917a 100644 --- a/src/state/error.rs +++ b/src/state/error.rs @@ -77,6 +77,7 @@ pub struct AppStateErrorContextInner { // all errors to strings. Maybe related to: // https://github.com/askama-rs/askama/issues/393 errors: Vec, + resolved_magnets_count: usize, } impl From for AppStateErrorContext { @@ -84,7 +85,10 @@ impl From for AppStateErrorContext { // An error is being displayed to the user, make sure it's also written in the logs e.log(); Self { - state: AppStateErrorContextInner { errors: vec![e] }, + state: AppStateErrorContextInner { + errors: vec![e], + resolved_magnets_count: 0, + }, } } } diff --git a/templates/content_folders/dropdown_actions.html b/templates/content_folders/dropdown_actions.html index f7db052..d54f0b8 100644 --- a/templates/content_folders/dropdown_actions.html +++ b/templates/content_folders/dropdown_actions.html @@ -4,7 +4,7 @@ diff --git a/templates/magnet/list.html b/templates/magnet/list.html index e3505c9..06f8244 100644 --- a/templates/magnet/list.html +++ b/templates/magnet/list.html @@ -1,33 +1,38 @@ {% extends "base.html" %} {% block main %} -
-

List of uploaded magnets not yet resolved

-
- - - - - - - - - {% for magnet in magnets %} - - - - - - {% endfor %} -
ResolvedNameActions
{{ magnet.resolved }}{{ magnet.name }} -
-
+
+ + {% endblock main %} diff --git a/templates/magnet/show.html b/templates/magnet/show.html deleted file mode 100644 index 887b7f4..0000000 --- a/templates/magnet/show.html +++ /dev/null @@ -1,5 +0,0 @@ -{% extends "base.html" %} - -{% block main %} -You successfully uploaded magnet {{ magnet.name }} -{% endblock main %} diff --git a/templates/menus/header.html b/templates/menus/header.html index b8122a9..f6b6f8b 100755 --- a/templates/menus/header.html +++ b/templates/menus/header.html @@ -13,9 +13,6 @@ - - - Add magnet - + {% if state.resolved_magnets_count != 0 %} + + + Waiting to be imported ({{ state.resolved_magnets_count }}) + + {% endif %} From b6b3acc4ef8dae157ebaadd3a5de1a3ffd5691e7 Mon Sep 17 00:00:00 2001 From: amateurforger Date: Wed, 18 Feb 2026 19:02:36 +0100 Subject: [PATCH 3/7] feat: Magnet upload form on the content folder page --- src/database/category.rs | 17 +++- src/database/content_folder.rs | 17 ++++ src/database/magnet.rs | 22 ++++- src/lib.rs | 6 +- src/routes/content_folder.rs | 56 +++++++++++-- src/routes/magnet.rs | 81 +++++-------------- .../content_folders/dropdown_actions.html | 43 +++++++++- templates/magnet/list.html | 4 +- templates/magnet/upload.html | 70 ---------------- 9 files changed, 170 insertions(+), 146 deletions(-) delete mode 100644 templates/magnet/upload.html diff --git a/src/database/category.rs b/src/database/category.rs index 5d8a06c..34793fe 100644 --- a/src/database/category.rs +++ b/src/database/category.rs @@ -51,6 +51,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"))] @@ -87,7 +89,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 { let category = Entity::find_by_id(id) .one(&self.state.database) @@ -100,6 +102,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 { + 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. diff --git a/src/database/content_folder.rs b/src/database/content_folder.rs index 4d6ecaa..56d06e6 100644 --- a/src/database/content_folder.rs +++ b/src/database/content_folder.rs @@ -32,6 +32,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 magnets: HasMany, } #[async_trait::async_trait] @@ -46,6 +48,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"))] @@ -118,6 +122,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 { + 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: diff --git a/src/database/magnet.rs b/src/database/magnet.rs index 7b81bcd..9867e1d 100644 --- a/src/database/magnet.rs +++ b/src/database/magnet.rs @@ -4,6 +4,7 @@ use sea_orm::entity::prelude::*; use sea_orm::*; use snafu::prelude::*; +use crate::database::content_folder; use crate::database::operation::*; use crate::database::{category, content_folder}; use crate::extractors::user::User; @@ -43,6 +44,10 @@ pub enum MagnetError { 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("The magnet (ID: {id}) does not exist"))] NotFound { id: i32 }, #[snafu(display("The magnet (TorrentID: {id}) does not exist"))] @@ -139,8 +144,22 @@ impl MagnetOperator { /// Fails if: /// /// - the magnet is invalid + /// - the requested content folder does not exist pub async fn create(&self, f: &MagnetForm) -> Result { - let magnet = MagnetLink::new(&f.magnet).context(InvalidMagnetSnafu)?; + let MagnetForm { + magnet, + content_folder_id, + } = f; + + let magnet = MagnetLink::new(magnet).context(InvalidMagnetSnafu)?; + + let content_folder = { + let operator = content_folder::ContentFolderOperator::new(self.state.clone(), None); + operator + .find_by_id_str(content_folder_id) + .await + .context(ContentFolderSnafu)? + }; // Check duplicates let list = self.list().await?; @@ -156,6 +175,7 @@ impl MagnetOperator { name: Set(magnet.name().to_string()), // TODO: check if we already have the torrent in which case it's already resolved! resolved: Set(false), + content_folder_id: Set(content_folder.id), ..Default::default() } .save(&self.state.database) diff --git a/src/lib.rs b/src/lib.rs index 2880220..761af44 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -31,10 +31,12 @@ pub fn router(state: state::AppState) -> Router { get(routes::content_folder::show), ) .route("/folders", get(routes::index::index)) + .route( + "/folders/{category_name}/{*folder_path}", + post(routes::content_folder::post_magnet), + ) .route("/folders", post(routes::content_folder::create)) .route("/logs", get(routes::logs::index)) - .route("/magnet/upload", post(routes::magnet::upload)) - .route("/magnet/upload", get(routes::magnet::get_upload)) .route("/magnet", get(routes::magnet::list)) // Register static assets routes .nest("/assets", static_router()) diff --git a/src/routes/content_folder.rs b/src/routes/content_folder.rs index ff85078..d3afe95 100644 --- a/src/routes/content_folder.rs +++ b/src/routes/content_folder.rs @@ -11,6 +11,7 @@ use crate::database::content_folder::PathBreadcrumb; use crate::database::{category, content_folder}; use crate::extractors::folder_request::FolderRequest; use crate::filesystem::FileSystemEntry; +use crate::routes::magnet::MagnetForm; use crate::state::flash_message::{OperationStatus, get_cookie}; use crate::state::{AppStateContext, error::*}; @@ -37,6 +38,32 @@ pub struct ContentFolderShowTemplate { pub breadcrumbs: Vec, /// Operation status for UI confirmation (Cookie) pub flash: Option, + // TODO: WIP + pub error: Option, +} + +impl ContentFolderShowTemplate { + fn new(context: AppStateContext, folder: FolderRequest) -> Self { + Self { + breadcrumbs: folder.breadcrumbs, + children: folder.children, + current_content_folder: folder.folder, + category: folder.category, + state: context, + flash: None, + error: None, + } + } + + fn with_flash(mut self, flash: Option) -> Self { + self.flash = flash; + self + } + + fn with_errored_form(mut self, _form: MagnetForm, error: AppStateError) -> Self { + self.error = Some(error); + self + } } pub async fn show( @@ -48,17 +75,30 @@ pub async fn show( Ok(( jar, - ContentFolderShowTemplate { - breadcrumbs: folder.breadcrumbs, - children: folder.children, - current_content_folder: folder.folder, - category: folder.category, - state: context, - flash: operation_status, - }, + ContentFolderShowTemplate::new(context, folder).with_flash(operation_status), )) } +pub async fn post_magnet( + context: AppStateContext, + folder: FolderRequest, + Form(form): Form, +) -> Result { + // TODO: proper error type + if let Err(e) = context + .db + .magnet() + .create(&form) + .await + .context(MagnetUploadSnafu) + { + return Err(ContentFolderShowTemplate::new(context, folder).with_errored_form(form, e)); + } + + // TODO: what to do when upload is successful? + Ok(Redirect::to("/magnet")) +} + pub async fn create( context: AppStateContext, jar: CookieJar, diff --git a/src/routes/magnet.rs b/src/routes/magnet.rs index 50d2bb1..915f657 100644 --- a/src/routes/magnet.rs +++ b/src/routes/magnet.rs @@ -1,11 +1,11 @@ use askama::Template; use askama_web::WebTemplate; -use axum::extract::Form; -use axum::response::{IntoResponse, Redirect, Response}; use serde::{Deserialize, Serialize}; use snafu::prelude::*; -use crate::database::{category, magnet}; +use sea_orm::LoaderTrait; + +use crate::database::{content_folder, magnet}; use crate::state::{AppStateContext, error::*}; /// Multipart form submitted to /magnet/upload: @@ -13,40 +13,20 @@ use crate::state::{AppStateContext, error::*}; /// - magnet: the magnet link to upload #[derive(Clone, Debug, Serialize, Deserialize)] pub struct MagnetForm { + pub content_folder_id: String, pub magnet: String, } -pub async fn upload( - context: AppStateContext, - Form(form): Form, -) -> Result { - // TODO: proper error type - if let Err(e) = context - .db - .magnet() - .create(&form) - .await - .context(MagnetUploadSnafu) - { - return Ok(UploadMagnetTemplate::new(context) - .await? - .with_errored_form(form, e) - .into_response()); - } - - Ok(Redirect::to("/magnet").into_response()) -} - #[derive(Template, WebTemplate)] #[template(path = "magnet/list.html")] pub struct MagnetListTemplate { /// Global application state (errors/warnings) pub state: AppStateContext, /// Magnets stored in database - pub magnets: Vec, + pub magnets: Vec<(magnet::Model, content_folder::Model)>, } -pub async fn list(context: AppStateContext) -> Result { +pub async fn list(context: AppStateContext) -> Result { let magnets = context .db .magnet() @@ -55,43 +35,22 @@ pub async fn list(context: AppStateContext) -> Result = magnets + .load_one(content_folder::Entity, &context.state.database) + .await + .context(SqliteSnafu)? + .into_iter() + .map(|x| x.unwrap()) + .collect(); + + let magnets = magnets + .into_iter() + .zip(content_folders.into_iter()) + .collect(); + Ok(MagnetListTemplate { state: context, magnets, }) } - -#[derive(Template, WebTemplate)] -#[template(path = "magnet/upload.html")] -pub struct UploadMagnetTemplate { - /// Global application state (errors/warnings) - pub state: AppStateContext, - /// Magnet upload form - pub post: Option, - /// Error with submitted magnet - pub post_error: Option, - pub categories: Vec, -} - -pub async fn get_upload(context: AppStateContext) -> Result { - UploadMagnetTemplate::new(context).await -} - -impl UploadMagnetTemplate { - pub async fn new(context: AppStateContext) -> Result { - let categories = context.db.category().list().await.context(CategorySnafu)?; - - Ok(UploadMagnetTemplate { - state: context, - categories, - post: None, - post_error: None, - }) - } - - pub fn with_errored_form(mut self, form: MagnetForm, error: AppStateError) -> Self { - self.post = Some(form); - self.post_error = Some(error); - self - } -} diff --git a/templates/content_folders/dropdown_actions.html b/templates/content_folders/dropdown_actions.html index d54f0b8..420e85c 100644 --- a/templates/content_folders/dropdown_actions.html +++ b/templates/content_folders/dropdown_actions.html @@ -4,7 +4,46 @@ + + diff --git a/templates/magnet/list.html b/templates/magnet/list.html index 06f8244..08d9390 100644 --- a/templates/magnet/list.html +++ b/templates/magnet/list.html @@ -11,12 +11,14 @@

List of uploaded magnets not yet resolved

Name + Content folder Actions - {% for magnet in magnets %} + {% for (magnet, content_folder) in magnets %} {{ magnet.name }} + {{ content_folder.path }} {% if magnet.resolved %} diff --git a/templates/magnet/upload.html b/templates/magnet/upload.html deleted file mode 100644 index fbb8d49..0000000 --- a/templates/magnet/upload.html +++ /dev/null @@ -1,70 +0,0 @@ -{% extends "base.html" %} - -{% block main %} -
-

Download files

- - {% if state.free_space.free_space_percent < 5 %} -
- The disks are almost full : {{ state.free_space.free_space_gib }} GiB left -
- {% endif %} - - {% if let Some(error) = post_error %} -
-
- {{ error }} -
    - {% for inner_error in error.inner_errors() %} -
  • → {{ inner_error }}
  • - {% endfor %} -
-
-
- {% endif %} - - -
-
-
-
- -
Choose the type of content you want to download.
- -
-
-
- -
-
- {% include "sources/magnet.html" %} -
- - - {% if categories.len() == 0 %} - - - - {% endif %} -
-{% endblock %} From a5bb852424f7b6257e574484793ec3dbb025d16c Mon Sep 17 00:00:00 2001 From: amateurforger Date: Thu, 19 Feb 2026 18:01:27 +0100 Subject: [PATCH 4/7] feat: Magnets also have an associated category --- src/database/magnet.rs | 51 +++++++++++++++++++----------------- src/routes/content_folder.rs | 7 ++--- src/routes/magnet.rs | 48 ++++++++++++++++++++++++++++++++- src/state/context.rs | 29 ++++++++++++++++++++ src/state/flash_message.rs | 2 +- src/state/free_space.rs | 1 + 6 files changed, 109 insertions(+), 29 deletions(-) diff --git a/src/database/magnet.rs b/src/database/magnet.rs index 9867e1d..5113978 100644 --- a/src/database/magnet.rs +++ b/src/database/magnet.rs @@ -4,11 +4,11 @@ use sea_orm::entity::prelude::*; use sea_orm::*; use snafu::prelude::*; -use crate::database::content_folder; use crate::database::operation::*; -use crate::database::{category, content_folder}; +use crate::database::{category, content_folder, operator::DatabaseOperator}; use crate::extractors::user::User; use crate::routes::magnet::MagnetForm; +use crate::routes::magnet::ValidatedMagnetForm; use crate::state::AppState; use crate::state::logger::LoggerError; @@ -48,6 +48,8 @@ pub enum MagnetError { 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"))] @@ -63,6 +65,10 @@ pub struct MagnetOperator { } impl MagnetOperator { + pub fn db(&self) -> DatabaseOperator { + DatabaseOperator::new(self.state.clone(), self.user.clone()) + } + /// List magnets /// /// Should not fail, unless SQLite was corrupted for some reason. @@ -145,21 +151,13 @@ impl MagnetOperator { /// /// - the magnet is invalid /// - the requested content folder does not exist - pub async fn create(&self, f: &MagnetForm) -> Result { - let MagnetForm { + pub async fn create(&self, form: MagnetForm) -> Result { + let validated_form = ValidatedMagnetForm::from_form(&form, &self.db()).await?; + let ValidatedMagnetForm { magnet, - content_folder_id, - } = f; - - let magnet = MagnetLink::new(magnet).context(InvalidMagnetSnafu)?; - - let content_folder = { - let operator = content_folder::ContentFolderOperator::new(self.state.clone(), None); - operator - .find_by_id_str(content_folder_id) - .await - .context(ContentFolderSnafu)? - }; + category, + content_folder, + } = validated_form; // Check duplicates let list = self.list().await?; @@ -169,21 +167,26 @@ impl MagnetOperator { return self.get_by_torrent_id(&magnet.id()).await; } - let model = ActiveModel { + 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), - content_folder_id: Set(content_folder.id), + category_id: Set(category.id), ..Default::default() + }; + + if let Some(content_folder) = content_folder { + model.content_folder_id = Set(content_folder.id); } - .save(&self.state.database) - .await - .context(DBSnafu)?; - // Should not fail - let model = model.try_into_model().unwrap(); + let model = model + .save(&self.state.database) + .await + .context(DBSnafu)? + .try_into_model() + .unwrap(); let operation_log = OperationLog { user: self.user.clone(), @@ -194,7 +197,7 @@ impl MagnetOperator { object_id: model.id.to_owned(), name: model.name.to_string(), }, - operation_form: Some(Operation::Magnet(f.clone())), + operation_form: Some(Operation::Magnet(form)), }; self.state diff --git a/src/routes/content_folder.rs b/src/routes/content_folder.rs index d3afe95..3997798 100644 --- a/src/routes/content_folder.rs +++ b/src/routes/content_folder.rs @@ -84,15 +84,16 @@ pub async fn post_magnet( folder: FolderRequest, Form(form): Form, ) -> Result { - // TODO: proper error type + let template = ContentFolderShowTemplate::new(context.partial_clone(), folder); + if let Err(e) = context .db .magnet() - .create(&form) + .create(form.clone()) .await .context(MagnetUploadSnafu) { - return Err(ContentFolderShowTemplate::new(context, folder).with_errored_form(form, e)); + return Err(template.with_errored_form(form, e)); } // TODO: what to do when upload is successful? diff --git a/src/routes/magnet.rs b/src/routes/magnet.rs index 915f657..7f0b77d 100644 --- a/src/routes/magnet.rs +++ b/src/routes/magnet.rs @@ -1,11 +1,12 @@ use askama::Template; use askama_web::WebTemplate; +use hightorrent_api::hightorrent::MagnetLink; use serde::{Deserialize, Serialize}; use snafu::prelude::*; use sea_orm::LoaderTrait; -use crate::database::{content_folder, magnet}; +use crate::database::{category, content_folder, magnet, operator::DatabaseOperator}; use crate::state::{AppStateContext, error::*}; /// Multipart form submitted to /magnet/upload: @@ -13,10 +14,55 @@ use crate::state::{AppStateContext, error::*}; /// - magnet: the magnet link to upload #[derive(Clone, Debug, Serialize, Deserialize)] pub struct MagnetForm { + pub category_id: String, pub content_folder_id: String, pub magnet: String, } +#[derive(Clone, Debug)] +pub struct ValidatedMagnetForm { + pub category: category::Model, + pub content_folder: Option, + pub magnet: MagnetLink, +} + +impl ValidatedMagnetForm { + pub async fn from_form( + f: &MagnetForm, + db: &DatabaseOperator, + ) -> Result { + let MagnetForm { + category_id, + content_folder_id, + magnet, + } = f; + + let magnet = MagnetLink::new(magnet).context(magnet::InvalidMagnetSnafu)?; + let category = db + .category() + .find_by_id_str(category_id) + .await + .context(magnet::CategorySnafu)?; + + let content_folder = if content_folder_id.is_empty() { + None + } else { + Some( + db.content_folder() + .find_by_id_str(content_folder_id) + .await + .context(magnet::ContentFolderSnafu)?, + ) + }; + + Ok(Self { + category, + content_folder, + magnet, + }) + } +} + #[derive(Template, WebTemplate)] #[template(path = "magnet/list.html")] pub struct MagnetListTemplate { diff --git a/src/state/context.rs b/src/state/context.rs index 3bdf07f..750d161 100644 --- a/src/state/context.rs +++ b/src/state/context.rs @@ -10,8 +10,11 @@ use super::*; /// Loading it may fail for some reasons, but it's so rare /// and unrecoverable that it will trigger a global error /// by rendering the AppStateError into an axum Response. +#[derive(Debug)] pub struct AppStateContext { pub db: DatabaseOperator, + // TODO: maybe use Arc> to make it clonable? Or + // Vec> ? pub errors: Vec, pub free_space: FreeSpace, pub resolved_magnets_count: usize, @@ -19,6 +22,32 @@ pub struct AppStateContext { pub user: Option, } +impl AppStateContext { + /// Clones the overall context, dropping errors. + /// + /// AppStateError has many variants, not all of which can implement clone. + pub fn partial_clone(&self) -> Self { + // Destructure so we don't forget new fields in the future + let Self { + db, + errors: _, + free_space, + resolved_magnets_count, + state, + user, + } = self; + + Self { + db: db.clone(), + errors: vec![], + free_space: free_space.clone(), + resolved_magnets_count: *resolved_magnets_count, + state: state.clone(), + user: user.clone(), + } + } +} + impl FromRequestParts for AppStateContext { type Rejection = AppStateError; diff --git a/src/state/flash_message.rs b/src/state/flash_message.rs index 0694d23..557c698 100644 --- a/src/state/flash_message.rs +++ b/src/state/flash_message.rs @@ -1,6 +1,6 @@ use axum_extra::extract::{CookieJar, cookie::Cookie}; -#[derive(Debug)] +#[derive(Clone, Debug)] pub struct OperationStatus { /// Status of operation pub success: bool, diff --git a/src/state/free_space.rs b/src/state/free_space.rs index dbde5fa..92dfdb5 100644 --- a/src/state/free_space.rs +++ b/src/state/free_space.rs @@ -28,6 +28,7 @@ pub enum FreeSpaceError { /// Remaining space on a partition. /// /// Uses (vendored) uu_df from uutils under the hood. +#[derive(Clone, Debug)] pub struct FreeSpace { /// Number of remaining GiB. pub free_space_gib: u64, From 61b012bba96cce5a05475106efd2c6c5b3c37acc Mon Sep 17 00:00:00 2001 From: amateurforger Date: Fri, 20 Feb 2026 09:29:12 +0100 Subject: [PATCH 5/7] feat: Display associated magnets on content folder view --- src/routes/content_folder.rs | 19 +++++++++++++------ .../content_folders/dropdown_actions.html | 1 + templates/content_folders/show.html | 16 ++++++++++++++-- 3 files changed, 28 insertions(+), 8 deletions(-) diff --git a/src/routes/content_folder.rs b/src/routes/content_folder.rs index 3997798..e1019d0 100644 --- a/src/routes/content_folder.rs +++ b/src/routes/content_folder.rs @@ -29,7 +29,7 @@ pub struct ContentFolderShowTemplate { /// Global application state pub state: AppStateContext, /// current folder - pub current_content_folder: content_folder::Model, + pub folder: content_folder::Model, /// Folders with parent_id set to current folder pub children: Vec, /// Category @@ -43,12 +43,19 @@ pub struct ContentFolderShowTemplate { } impl ContentFolderShowTemplate { - fn new(context: AppStateContext, folder: FolderRequest) -> Self { + pub fn new(context: AppStateContext, folder: FolderRequest) -> Self { + let FolderRequest { + children, + folder, + category, + breadcrumbs, + } = folder; + Self { - breadcrumbs: folder.breadcrumbs, - children: folder.children, - current_content_folder: folder.folder, - category: folder.category, + breadcrumbs, + children, + folder, + category, state: context, flash: None, error: None, diff --git a/templates/content_folders/dropdown_actions.html b/templates/content_folders/dropdown_actions.html index 420e85c..2f0b869 100644 --- a/templates/content_folders/dropdown_actions.html +++ b/templates/content_folders/dropdown_actions.html @@ -33,6 +33,7 @@

Import Magnet Link

{% if current_content_folder is defined %} + {% endif %} diff --git a/templates/content_folders/show.html b/templates/content_folders/show.html index 280cfaa..fa6efa6 100644 --- a/templates/content_folders/show.html +++ b/templates/content_folders/show.html @@ -1,9 +1,21 @@ {% extends "layouts/file_system_base.html" %} {% block folder_title %} - {{ current_content_folder.name }} + {{ folder.name }} {% endblock%} +{% block alert_message %} + {% include "shared/alert_operation_status.html" %} + {% if let Some(error) = error %} +
+

{{ error }}

+ {% for inner_error in error.inner_errors() %} +

→ {{ inner_error }}

+ {% endfor %} +
+ {% endif %} +{% endblock %} + {% block actions_buttons %} {% include "content_folders/dropdown_actions.html" %} {% endblock %} @@ -22,7 +34,7 @@
- +
From 7af683742889b001ab3fef9f8bedcd970e1ded25 Mon Sep 17 00:00:00 2001 From: amateurforger Date: Fri, 20 Feb 2026 13:40:57 +0100 Subject: [PATCH 6/7] feat: Upload magnets directly into category --- src/database/magnet.rs | 4 +- src/extractors/category_request.rs | 53 ++++++++++++ src/extractors/mod.rs | 1 + src/lib.rs | 4 + src/routes/category.rs | 84 +++++++++++++------ src/routes/magnet.rs | 22 +++-- .../content_folders/dropdown_actions.html | 2 +- 7 files changed, 131 insertions(+), 39 deletions(-) create mode 100644 src/extractors/category_request.rs diff --git a/src/database/magnet.rs b/src/database/magnet.rs index 5113978..1b907c0 100644 --- a/src/database/magnet.rs +++ b/src/database/magnet.rs @@ -26,7 +26,7 @@ pub struct Model { pub link: MagnetLink, pub name: String, pub resolved: bool, - pub content_folder_id: i32, + pub content_folder_id: Option, #[sea_orm(belongs_to, from = "content_folder_id", to = "id")] pub content_folder: HasOne, pub category_id: i32, @@ -178,7 +178,7 @@ impl MagnetOperator { }; if let Some(content_folder) = content_folder { - model.content_folder_id = Set(content_folder.id); + model.content_folder_id = Set(Some(content_folder.id)); } let model = model diff --git a/src/extractors/category_request.rs b/src/extractors/category_request.rs new file mode 100644 index 0000000..154417c --- /dev/null +++ b/src/extractors/category_request.rs @@ -0,0 +1,53 @@ +use axum::extract::{FromRequestParts, Path}; +use axum::http::request::Parts; +use snafu::prelude::*; + +use crate::database::category::{self, CategoryOperator}; +use crate::database::content_folder::PathBreadcrumb; +use crate::filesystem::FileSystemEntry; +use crate::state::{AppState, error::*}; + +#[derive(Clone, Debug)] +pub struct CategoryRequest { + pub category: category::Model, + pub breadcrumbs: Vec, + pub children: Vec, +} + +impl FromRequestParts for CategoryRequest { + type Rejection = AppStateError; + + async fn from_request_parts( + parts: &mut Parts, + app_state: &AppState, + ) -> Result { + let Path(category_name) = + as FromRequestParts>::from_request_parts(parts, app_state) + .await + .unwrap(); + + // Read-only operators: no need to extract the current user + let categories = CategoryOperator::new(app_state.clone(), None); + + let category = categories + .find_by_name(category_name.to_string()) + .await + .context(CategorySnafu)?; + + // get all content folders in this category + let content_folders = categories + .list_folders(category.id) + .await + .context(CategorySnafu)?; + + let children = FileSystemEntry::from_content_folders(&category, &content_folders); + + let breadcrumbs = PathBreadcrumb::for_filesystem_path(category.name.as_str()); + + Ok(Self { + category, + children, + breadcrumbs, + }) + } +} diff --git a/src/extractors/mod.rs b/src/extractors/mod.rs index 6537af5..a95eb77 100644 --- a/src/extractors/mod.rs +++ b/src/extractors/mod.rs @@ -1,3 +1,4 @@ +pub mod category_request; pub mod folder_request; pub mod normalized_path; pub mod torrent_list; diff --git a/src/lib.rs b/src/lib.rs index 761af44..8c49b71 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -26,6 +26,10 @@ pub fn router(state: state::AppState) -> Router { .route("/categories/new", get(routes::category::new)) .route("/categories/{id}/delete", get(routes::category::delete)) .route("/folders/{category_id}", get(routes::category::show)) + .route( + "/folders/{category_id}", + post(routes::category::post_magnet), + ) .route( "/folders/{category_name}/{*folder_path}", get(routes::content_folder::show), diff --git a/src/routes/category.rs b/src/routes/category.rs index 9d2e578..5a593a2 100644 --- a/src/routes/category.rs +++ b/src/routes/category.rs @@ -9,8 +9,10 @@ use snafu::prelude::*; use crate::database::category::{self, CategoryError}; use crate::database::content_folder::PathBreadcrumb; +use crate::extractors::category_request::CategoryRequest; use crate::extractors::normalized_path::*; use crate::filesystem::FileSystemEntry; +use crate::routes::magnet::MagnetForm; use crate::state::flash_message::{OperationStatus, get_cookie}; use crate::state::{AppStateContext, error::*}; @@ -105,40 +107,68 @@ pub struct CategoryShowTemplate { pub flash: Option, /// Breadcrumbs navigation pub breadcrumbs: Vec, + pub error: Option, } -pub async fn show( - context: AppStateContext, - Path(category_name): Path, - jar: CookieJar, -) -> Result { - let categories = context.db.category(); +impl CategoryShowTemplate { + pub fn new(context: AppStateContext, category: CategoryRequest) -> Self { + let CategoryRequest { + category, + children, + breadcrumbs, + } = category; - let category = categories - .find_by_name(category_name.to_string()) - .await - .context(CategorySnafu)?; + Self { + state: context, + children, + breadcrumbs, + category, + flash: None, + error: None, + } + } - // get all content folders in this category - let content_folders = categories - .list_folders(category.id) - .await - .context(CategorySnafu)?; + fn with_flash(mut self, flash: Option) -> Self { + self.flash = flash; + self + } - let children = FileSystemEntry::from_content_folders(&category, &content_folders); + fn with_errored_form(mut self, _form: MagnetForm, error: AppStateError) -> Self { + self.error = Some(error); + self + } +} +pub async fn show( + context: AppStateContext, + category: CategoryRequest, + jar: CookieJar, +) -> (CookieJar, CategoryShowTemplate) { let (jar, operation_status) = get_cookie(jar); - let breadcrumbs = PathBreadcrumb::for_filesystem_path(category.name.as_str()); - - Ok(( + ( jar, - CategoryShowTemplate { - category, - children, - state: context, - flash: operation_status, - breadcrumbs, - }, - )) + CategoryShowTemplate::new(context, category).with_flash(operation_status), + ) +} + +pub async fn post_magnet( + context: AppStateContext, + category: CategoryRequest, + Form(form): Form, +) -> Result { + let template = CategoryShowTemplate::new(context.partial_clone(), category); + + if let Err(e) = context + .db + .magnet() + .create(form.clone()) + .await + .context(MagnetUploadSnafu) + { + return Err(template.with_errored_form(form, e)); + } + + // TODO: what to do when upload is successful? + Ok(Redirect::to("/magnet")) } diff --git a/src/routes/magnet.rs b/src/routes/magnet.rs index 7f0b77d..d158ab9 100644 --- a/src/routes/magnet.rs +++ b/src/routes/magnet.rs @@ -15,7 +15,7 @@ use crate::state::{AppStateContext, error::*}; #[derive(Clone, Debug, Serialize, Deserialize)] pub struct MagnetForm { pub category_id: String, - pub content_folder_id: String, + pub content_folder_id: Option, pub magnet: String, } @@ -44,15 +44,19 @@ impl ValidatedMagnetForm { .await .context(magnet::CategorySnafu)?; - let content_folder = if content_folder_id.is_empty() { - None + let content_folder = if let Some(content_folder_id) = content_folder_id { + if content_folder_id.is_empty() { + None + } else { + Some( + db.content_folder() + .find_by_id_str(content_folder_id) + .await + .context(magnet::ContentFolderSnafu)?, + ) + } } else { - Some( - db.content_folder() - .find_by_id_str(content_folder_id) - .await - .context(magnet::ContentFolderSnafu)?, - ) + None }; Ok(Self { diff --git a/templates/content_folders/dropdown_actions.html b/templates/content_folders/dropdown_actions.html index 2f0b869..a5e3d79 100644 --- a/templates/content_folders/dropdown_actions.html +++ b/templates/content_folders/dropdown_actions.html @@ -32,8 +32,8 @@

Import Magnet Link

+ {% if current_content_folder is defined %} - {% endif %} From 3b5d68b3dd8990b2d07563f3ff66d89c3c96ac36 Mon Sep 17 00:00:00 2001 From: amateurforger Date: Fri, 20 Feb 2026 14:08:57 +0100 Subject: [PATCH 7/7] fix: Proper/link display of category/folder in magnet list --- Cargo.lock | 1 + Cargo.toml | 1 + src/routes/magnet.rs | 19 +++++++++++++------ templates/magnet/list.html | 8 ++++++-- 4 files changed, 21 insertions(+), 8 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 70b1348..38dcc07 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3820,6 +3820,7 @@ dependencies = [ "derive_more", "env_logger", "hightorrent_api", + "itertools", "log", "sea-orm", "sea-orm-migration", diff --git a/Cargo.toml b/Cargo.toml index 9ab8398..9497ff3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,6 +37,7 @@ env_logger = "0.11.8" # Comment/uncomment below for development version # hightorrent_api = { path = "../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 diff --git a/src/routes/magnet.rs b/src/routes/magnet.rs index d158ab9..27443e4 100644 --- a/src/routes/magnet.rs +++ b/src/routes/magnet.rs @@ -1,6 +1,7 @@ use askama::Template; use askama_web::WebTemplate; use hightorrent_api::hightorrent::MagnetLink; +use itertools::multizip; use serde::{Deserialize, Serialize}; use snafu::prelude::*; @@ -73,7 +74,11 @@ pub struct MagnetListTemplate { /// Global application state (errors/warnings) pub state: AppStateContext, /// Magnets stored in database - pub magnets: Vec<(magnet::Model, content_folder::Model)>, + pub magnets: Vec<( + magnet::Model, + category::Model, + Option, + )>, } pub async fn list(context: AppStateContext) -> Result { @@ -86,18 +91,20 @@ pub async fn list(context: AppStateContext) -> Result = magnets + let content_folders: Vec> = magnets .load_one(content_folder::Entity, &context.state.database) .await + .context(SqliteSnafu)?; + + let categories: Vec = magnets + .load_one(category::Entity, &context.state.database) + .await .context(SqliteSnafu)? .into_iter() .map(|x| x.unwrap()) .collect(); - let magnets = magnets - .into_iter() - .zip(content_folders.into_iter()) - .collect(); + let magnets = multizip((magnets, categories, content_folders)).collect(); Ok(MagnetListTemplate { state: context, diff --git a/templates/magnet/list.html b/templates/magnet/list.html index 08d9390..66fc670 100644 --- a/templates/magnet/list.html +++ b/templates/magnet/list.html @@ -15,10 +15,14 @@

List of uploaded magnets not yet resolved

Actions - {% for (magnet, content_folder) in magnets %} + {% for (magnet, category, content_folder) in magnets %} {{ magnet.name }} - {{ content_folder.path }} + {% if let Some(content_folder) = content_folder %} + {{ category.name }}{{ content_folder.path }} + {% else %} + {{ category.name }} + {% endif %} {% if magnet.resolved %}