From 1251f4c4fea579b9f2dffaf429b0a98afb08d2dc Mon Sep 17 00:00:00 2001 From: Stirling Mouse <181794392+StirlingMouse@users.noreply.github.com> Date: Thu, 12 Mar 2026 22:26:24 +0100 Subject: [PATCH] Move Dioxus routes and extract web API Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Cargo.lock | 26 ++++ Cargo.toml | 3 + mlm_web_api/Cargo.toml | 24 +++ mlm_web_api/src/download.rs | 69 +++++++++ mlm_web_api/src/error.rs | 32 ++++ mlm_web_api/src/lib.rs | 80 ++++++++++ mlm_web_api/src/search.rs | 137 +++++++++++++++++ mlm_web_api/src/torrent.rs | 90 +++++++++++ mlm_web_askama/src/lib.rs | 140 ++++-------------- mlm_web_askama/src/pages/torrent.rs | 58 +------- mlm_web_askama/src/pages/torrent_edit.rs | 4 +- mlm_web_askama/templates/base.html | 16 +- mlm_web_askama/templates/pages/duplicate.html | 2 +- mlm_web_askama/templates/pages/errors.html | 2 +- mlm_web_askama/templates/pages/list.html | 16 +- mlm_web_askama/templates/pages/lists.html | 4 +- mlm_web_askama/templates/pages/replaced.html | 4 +- mlm_web_askama/templates/pages/torrent.html | 4 +- mlm_web_askama/templates/pages/torrents.html | 2 +- .../templates/partials/mam_torrents.html | 8 +- mlm_web_dioxus/src/app.rs | 28 ++-- mlm_web_dioxus/src/components/filter_link.rs | 2 +- mlm_web_dioxus/src/components/search_row.rs | 2 +- mlm_web_dioxus/src/errors/components.rs | 2 +- mlm_web_dioxus/src/events/components.rs | 2 +- mlm_web_dioxus/src/list.rs | 2 +- mlm_web_dioxus/src/sse.rs | 10 +- mlm_web_dioxus/src/ssr.rs | 10 +- .../src/torrent_detail/components.rs | 4 +- mlm_web_dioxus/src/torrent_edit.rs | 2 +- server/Cargo.toml | 2 + server/src/main.rs | 5 +- 32 files changed, 566 insertions(+), 226 deletions(-) create mode 100644 mlm_web_api/Cargo.toml create mode 100644 mlm_web_api/src/download.rs create mode 100644 mlm_web_api/src/error.rs create mode 100644 mlm_web_api/src/lib.rs create mode 100644 mlm_web_api/src/search.rs create mode 100644 mlm_web_api/src/torrent.rs diff --git a/Cargo.lock b/Cargo.lock index ddbd5928..16564ef6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3504,6 +3504,7 @@ dependencies = [ "mlm_mam", "mlm_meta", "mlm_parse", + "mlm_web_api", "mlm_web_askama", "mlm_web_dioxus", "native_db", @@ -3515,6 +3516,7 @@ dependencies = [ "tempfile", "time", "tokio", + "tower-http", "tracing", "tracing-appender", "tracing-panic", @@ -3655,6 +3657,30 @@ dependencies = [ "unidecode", ] +[[package]] +name = "mlm_web_api" +version = "0.1.0" +dependencies = [ + "anyhow", + "axum", + "axum-extra", + "mime_guess", + "mlm_core", + "mlm_db", + "mlm_mam", + "native_db", + "qbit", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "tokio-util", + "toml 0.8.23", + "tower", + "tower-http", + "tracing", +] + [[package]] name = "mlm_web_askama" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index e0bdb3b0..24cc0954 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,6 +2,7 @@ resolver = "3" members = [ "server", + "mlm_web_api", "mlm_db", "mlm_parse", "mlm_mam", @@ -29,6 +30,8 @@ opt-level = 0 opt-level = 0 [profile.dev.package.mlm_parse] opt-level = 0 +[profile.dev.package.mlm_web_api] +opt-level = 0 [profile.dev.package.mlm_web_askama] opt-level = 0 diff --git a/mlm_web_api/Cargo.toml b/mlm_web_api/Cargo.toml new file mode 100644 index 00000000..871485ab --- /dev/null +++ b/mlm_web_api/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "mlm_web_api" +version = "0.1.0" +edition = "2024" + +[dependencies] +anyhow = "1.0.100" +axum = { version = "0.8.4", features = ["query"] } +axum-extra = { version = "0.10.1", features = ["form"] } +mime_guess = "2.0.5" +mlm_core = { path = "../mlm_core" } +mlm_db = { path = "../mlm_db" } +mlm_mam = { path = "../mlm_mam" } +native_db = { git = "https://github.com/StirlingMouse/native_db.git", branch = "0.8.x" } +qbit = { git = "https://github.com/StirlingMouse/qbittorrent-webui-api.git" } +serde = "1.0.136" +serde_json = "1.0.140" +thiserror = "2.0.17" +tokio = { version = "1.45.1", features = ["fs"] } +tokio-util = "0.7" +tower = "0.5.2" +tower-http = { version = "0.6.6", features = ["fs"] } +toml = "0.8.23" +tracing = "0.1.41" diff --git a/mlm_web_api/src/download.rs b/mlm_web_api/src/download.rs new file mode 100644 index 00000000..bed43b24 --- /dev/null +++ b/mlm_web_api/src/download.rs @@ -0,0 +1,69 @@ +use axum::{ + body::Body, + extract::{Path, State}, + response::IntoResponse, +}; +use mlm_db::Torrent; +use tokio_util::io::ReaderStream; +use tracing::warn; + +use crate::error::AppError; +use mlm_core::{ + Context, ContextExt, + linker::map_path, + qbittorrent::{self}, +}; + +pub async fn torrent_file( + State(context): State, + Path((id, filename)): Path<(String, String)>, +) -> Result { + let config = context.config().await; + let Some(torrent) = context.db().r_transaction()?.get().primary::(id)? else { + return Err(AppError::NotFound); + }; + let Some(path) = (if let (Some(library_path), Some(library_file)) = ( + &torrent.library_path, + torrent + .library_files + .iter() + .find(|f| f.to_string_lossy() == filename), + ) { + Some(library_path.join(library_file)) + } else if let Some((torrent, qbit, qbit_config)) = + qbittorrent::get_torrent(&config, &torrent.id).await? + { + qbit.files(&torrent.hash, None) + .await? + .into_iter() + .find(|f| f.name == filename) + .map(|file| map_path(&qbit_config.path_mapping, &torrent.save_path).join(&file.name)) + } else { + None + }) else { + return Err(AppError::NotFound); + }; + let file = match tokio::fs::File::open(&path).await { + Ok(file) => file, + Err(err) => { + warn!("Failed opening torrent file {}: {err}", path.display()); + return Err(AppError::NotFound); + } + }; + let stream = ReaderStream::new(file); + let body = Body::from_stream(stream); + let content_type = mime_guess::from_path(&filename) + .first_or_octet_stream() + .to_string(); + let safe_filename = filename.replace(['\r', '\n', '"'], "_"); + + let headers = [ + (axum::http::header::CONTENT_TYPE, content_type), + ( + axum::http::header::CONTENT_DISPOSITION, + format!("attachment; filename=\"{}\"", safe_filename), + ), + ]; + + Ok((headers, body)) +} diff --git a/mlm_web_api/src/error.rs b/mlm_web_api/src/error.rs new file mode 100644 index 00000000..7acf501f --- /dev/null +++ b/mlm_web_api/src/error.rs @@ -0,0 +1,32 @@ +use axum::{ + http::StatusCode, + response::{IntoResponse, Response}, +}; + +#[derive(Debug, thiserror::Error)] +pub enum AppError { + #[error("Could not query db: {0}")] + Db(#[from] native_db::db_type::Error), + #[error("Meta Error: {0:?}")] + MetaError(#[from] mlm_mam::meta::MetaError), + #[error("Qbit Error: {0:?}")] + QbitError(#[from] qbit::Error), + #[error("Toml Parse Error: {0:?}")] + Toml(#[from] toml::de::Error), + #[error("Error: {0:?}")] + Generic(#[from] anyhow::Error), + #[error("JSON Error: {0}")] + Json(#[from] serde_json::Error), + #[error("Page Not Found")] + NotFound, +} + +impl IntoResponse for AppError { + fn into_response(self) -> Response { + let status = match self { + AppError::NotFound => StatusCode::NOT_FOUND, + _ => StatusCode::INTERNAL_SERVER_ERROR, + }; + (status, self.to_string()).into_response() + } +} diff --git a/mlm_web_api/src/lib.rs b/mlm_web_api/src/lib.rs new file mode 100644 index 00000000..462877f1 --- /dev/null +++ b/mlm_web_api/src/lib.rs @@ -0,0 +1,80 @@ +mod download; +mod error; +mod search; +mod torrent; + +use std::path::PathBuf; + +use axum::{ + Router, + body::Body, + http::{HeaderValue, Request}, + middleware::{self, Next}, + response::Response, + routing::get, +}; +use mlm_core::Context; +use tower::ServiceBuilder; +use tower_http::services::{ServeDir, ServeFile}; + +use crate::{ + download::torrent_file, + search::{search_api, search_api_post}, + torrent::torrent_api, +}; + +pub fn router(context: Context, dioxus_public_path: PathBuf) -> Router { + let dioxus_assets_path = dioxus_public_path.join("assets"); + let app = Router::new() + .route("/api/search", get(search_api).post(search_api_post)) + .route( + "/api/torrents/{id}", + get(torrent_api).with_state(context.clone()), + ) + .route( + "/torrents/{id}/{filename}", + get(torrent_file).with_state(context.clone()), + ) + .with_state(context.clone()) + .nest_service( + "/assets", + ServiceBuilder::new() + .layer(middleware::from_fn(set_static_cache_control)) + .service(ServeDir::new(dioxus_assets_path).fallback(ServeDir::new("server/assets"))), + ); + + #[cfg(debug_assertions)] + let app = app.nest_service( + "/assets/favicon.png", + ServiceBuilder::new() + .layer(middleware::from_fn(set_static_cache_control)) + .service(ServeFile::new("server/assets/favicon_dev.png")), + ); + + #[cfg(debug_assertions)] + let app = app.nest_service( + "/favicon.ico", + ServiceBuilder::new() + .layer(middleware::from_fn(set_static_cache_control)) + .service(ServeFile::new("server/assets/favicon_dev.png")), + ); + + #[cfg(not(debug_assertions))] + let app = app.nest_service( + "/favicon.ico", + ServiceBuilder::new() + .layer(middleware::from_fn(set_static_cache_control)) + .service(ServeFile::new("server/assets/favicon.png")), + ); + + app +} + +async fn set_static_cache_control(request: Request, next: Next) -> Response { + let mut response = next.run(request).await; + response.headers_mut().insert( + axum::http::header::CACHE_CONTROL, + HeaderValue::from_static("must-revalidate"), + ); + response +} diff --git a/mlm_web_api/src/search.rs b/mlm_web_api/src/search.rs new file mode 100644 index 00000000..047e7ce1 --- /dev/null +++ b/mlm_web_api/src/search.rs @@ -0,0 +1,137 @@ +use std::{fs::File, path::PathBuf}; + +use anyhow::Result; +use axum::{ + Json, + extract::{Query, State}, +}; +use axum_extra::extract::Form; +use mlm_mam::search::{MaMTorrent, SearchFields}; +use serde::{Deserialize, Serialize}; +use tokio::fs::create_dir_all; + +use crate::error::AppError; +use mlm_core::config::{Cost, TorrentSearch}; +use mlm_core::{ + Context, ContextExt, + autograbber::{mark_removed_torrents, search_torrents, select_torrents}, +}; + +pub async fn search_api( + State(context): State, + Query(query): Query, +) -> std::result::Result, AppError> { + let mam = context.mam()?; + let search: TorrentSearch = toml::from_str(&query.toml)?; + let torrents = search_torrents( + &search, + SearchFields { + description: true, + isbn: true, + media_info: true, + ..Default::default() + }, + &mam, + ) + .await? + .collect::>(); + + Ok(Json(SearchApiResponse { + torrents: Some(torrents), + ..Default::default() + })) +} + +pub async fn search_api_post( + State(context): State, + Form(form): Form, +) -> Result, AppError> { + let config = context.config().await; + let mam = context.mam()?; + let search: TorrentSearch = toml::from_str(&form.toml)?; + let torrents = search_torrents( + &search, + SearchFields { + description: true, + isbn: true, + media_info: true, + dl_link: form.add + && search.cost != Cost::MetadataOnly + && search.cost != Cost::MetadataOnlyAdd, + ..Default::default() + }, + &mam, + ) + .await? + .collect::>(); + if form.write_json { + for torrent in &torrents { + let id_str = torrent.id.to_string(); + let first = id_str.get(0..1).unwrap_or_default(); + let second = id_str.get(1..2).unwrap_or_default(); + let third = id_str.get(3..4).unwrap_or_default(); + let path = PathBuf::from("/data/torrents") + .join(first) + .join(second) + .join(third); + create_dir_all(&path).await.map_err(anyhow::Error::new)?; + let file_path = path.join(format!("{}.json", torrent.id)); + let file = File::create(file_path).map_err(anyhow::Error::new)?; + serde_json::to_writer(file, torrent)?; + } + } + + if form.mark_removed { + mark_removed_torrents(context.db(), &mam, &torrents, &context.events).await?; + } + + if form.add { + select_torrents( + &config, + context.db(), + &mam, + torrents.into_iter(), + &search.filter, + search.cost, + search.unsat_buffer, + search.wedge_buffer, + search.category.clone(), + search.dry_run, + u64::MAX, + None, + &context.events, + ) + .await?; + return Ok(Json(SearchApiResponse { + added: Some(true), + ..Default::default() + })); + } + + Ok(Json(SearchApiResponse { + torrents: Some(torrents), + ..Default::default() + })) +} + +#[derive(Debug, Deserialize)] +pub struct SearchApiForm { + toml: String, + #[serde(default)] + add: bool, + #[serde(default)] + mark_removed: bool, + #[serde(default)] + write_json: bool, +} + +#[derive(Deserialize)] +pub struct SearchApiQuery { + toml: String, +} + +#[derive(Default, Serialize)] +pub struct SearchApiResponse { + torrents: Option>, + added: Option, +} diff --git a/mlm_web_api/src/torrent.rs b/mlm_web_api/src/torrent.rs new file mode 100644 index 00000000..4ea21ab3 --- /dev/null +++ b/mlm_web_api/src/torrent.rs @@ -0,0 +1,90 @@ +use axum::{ + Json, + extract::{Path, State}, +}; +use mlm_db::{Torrent, TorrentKey}; +use serde_json::json; + +use crate::error::AppError; +use mlm_core::{ + Context, ContextExt, + qbittorrent::{self}, +}; + +pub async fn torrent_api( + State(context): State, + Path(id_or_mam_id): Path, +) -> std::result::Result, AppError> { + if let Some(torrent) = context + .db() + .r_transaction()? + .get() + .primary::(id_or_mam_id.clone())? + { + if torrent.id_is_hash { + return torrent_api_id(State(context), Path(torrent.id)).await; + } + if let Some(mam_id) = torrent.mam_id { + return torrent_api_mam_id(State(context), Path(mam_id)).await; + } + return torrent_api_id(State(context), Path(torrent.id)).await; + } + + let mam_id = id_or_mam_id.parse().map_err(|_| AppError::NotFound)?; + torrent_api_mam_id(State(context), Path(mam_id)).await +} + +async fn torrent_api_mam_id( + State(context): State, + Path(mam_id): Path, +) -> std::result::Result, AppError> { + if let Some(torrent) = context + .db() + .r_transaction()? + .get() + .secondary::(TorrentKey::mam_id, mam_id)? + { + return torrent_api_id(State(context), Path(torrent.id)).await; + }; + + let mam = context.mam()?; + let Some(mam_torrent) = mam.get_torrent_info_by_id(mam_id).await? else { + return Err(AppError::NotFound); + }; + let meta = mam_torrent.as_meta()?; + + Ok(Json(json!({ + "mam_torrent": mam_torrent, + "meta": meta, + }))) +} + +async fn torrent_api_id( + State(context): State, + Path(id): Path, +) -> std::result::Result, AppError> { + let config = context.config().await; + let Some(torrent) = context.db().r_transaction()?.get().primary::(id)? else { + return Err(AppError::NotFound); + }; + let mut qbit_torrent = None; + let mut qbit_files = vec![]; + if torrent.id_is_hash + && let Some((qbit_torrent_, qbit, _)) = + qbittorrent::get_torrent(&config, &torrent.id).await? + { + qbit_torrent = Some(qbit_torrent_); + qbit_files = qbit.files(&torrent.id, None).await?; + } + + Ok(Json(json!({ + "abs_url": config + .audiobookshelf + .as_ref() + .map(|abs| abs.url.clone()) + .unwrap_or_default(), + "torrent": torrent, + "qbit_torrent": qbit_torrent, + "qbit_files": qbit_files, + }))) +} diff --git a/mlm_web_askama/src/lib.rs b/mlm_web_askama/src/lib.rs index d7d8d76b..8b380c74 100644 --- a/mlm_web_askama/src/lib.rs +++ b/mlm_web_askama/src/lib.rs @@ -1,17 +1,11 @@ -mod api; mod pages; mod tables; -use std::sync::Arc; - -use anyhow::Result; use askama::{Template, filters::HtmlSafe}; use axum::{ Router, - body::Body, extract::OriginalUri, - http::{HeaderValue, Request, StatusCode}, - middleware::{self, Next}, + http::StatusCode, response::{Html, IntoResponse, Redirect, Response}, routing::{get, post}, }; @@ -20,7 +14,7 @@ use mlm_db::{ AudiobookCategory, EbookCategory, Flags, SelectedTorrent, Series, Timestamp, Torrent, TorrentMeta, }; -use mlm_mam::{api::MaM, meta::MetaError, search::MaMTorrent, serde::DATE_FORMAT}; +use mlm_mam::{meta::MetaError, search::MaMTorrent, serde::DATE_FORMAT}; use once_cell::sync::Lazy; use pages::{ duplicate::{duplicate_page, duplicate_torrents_page_post}, @@ -30,11 +24,10 @@ use pages::{ lists::lists_page, replaced::{replaced_torrents_page, replaced_torrents_page_post}, selected::{selected_page, selected_torrents_page_post}, - torrent::{torrent_file, torrent_page, torrent_page_post}, + torrent::{torrent_page, torrent_page_post}, torrent_edit::{torrent_edit_page, torrent_edit_page_post}, torrents::{torrents_page, torrents_page_post}, }; -use reqwest::header; use serde::Serialize; use tables::{ItemFilter, ItemFilters, Key}; use time::{ @@ -42,148 +35,88 @@ use time::{ format_description::{self, OwnedFormatItem}, }; use tokio::sync::watch::error::SendError; -use tower::ServiceBuilder; -#[allow(unused)] -pub use tower_http::services::{ServeDir, ServeFile}; - -use crate::{ - api::{ - search::{search_api, search_api_post}, - torrent::torrent_api, - }, - pages::{ - index::stats_updates, - search::{search_page, search_page_post}, - }, +use crate::pages::{ + index::stats_updates, + search::{search_page, search_page_post}, }; use mlm_core::config::{SearchConfig, TorrentFilter}; use mlm_core::{Context, ContextExt}; -pub type MaMState = Arc>>>; - pub fn router(context: Context) -> Router { - let app = Router::new() + Router::new() .route( - "/stats-updates", + "/old/stats-updates", get(stats_updates).with_state(context.clone()), ) - .route("/torrents", get(torrents_page).with_state(context.clone())) + .route("/old/torrents", get(torrents_page).with_state(context.clone())) .route( - "/torrents", + "/old/torrents", post(torrents_page_post).with_state(context.clone()), ) .route( - "/torrents/{id}", + "/old/torrents/{id}", get(torrent_page).with_state(context.clone()), ) .route( - "/torrents/{id}", + "/old/torrents/{id}", post(torrent_page_post).with_state(context.clone()), ) .route( - "/torrents/{id}/edit", + "/old/torrents/{id}/edit", get(torrent_edit_page).with_state(context.db().clone()), ) .route( - "/torrents/{id}/edit", + "/old/torrents/{id}/edit", post(torrent_edit_page_post).with_state(context.clone()), ) + .route("/old/events", get(event_page).with_state(context.db().clone())) + .route("/old/search", get(search_page).with_state(context.clone())) .route( - "/torrents/{id}/{filename}", - get(torrent_file).with_state(context.clone()), - ) - .route("/events", get(event_page).with_state(context.db().clone())) - .route("/search", get(search_page).with_state(context.clone())) - .route( - "/search", + "/old/search", post(search_page_post).with_state(context.clone()), ) - .route("/lists", get(lists_page).with_state(context.clone())) + .route("/old/lists", get(lists_page).with_state(context.clone())) .route( - "/lists/{list_id}", + "/old/lists/{list_id}", get(list_page).with_state(context.db().clone()), ) .route( - "/lists/{list_id}", + "/old/lists/{list_id}", post(list_page_post).with_state(context.db().clone()), ) - .route("/errors", get(errors_page).with_state(context.db().clone())) + .route("/old/errors", get(errors_page).with_state(context.db().clone())) .route( - "/errors", + "/old/errors", post(errors_page_post).with_state(context.db().clone()), ) - .route("/selected", get(selected_page).with_state(context.clone())) + .route("/old/selected", get(selected_page).with_state(context.clone())) .route( - "/selected", + "/old/selected", post(selected_torrents_page_post).with_state(context.db().clone()), ) .route( - "/replaced", + "/old/replaced", get(replaced_torrents_page).with_state(context.clone()), ) .route( - "/replaced", + "/old/replaced", post(replaced_torrents_page_post).with_state(context.clone()), ) .route( - "/duplicate", + "/old/duplicate", get(duplicate_page).with_state(context.clone()), ) .route( - "/duplicate", + "/old/duplicate", post(duplicate_torrents_page_post).with_state(context.clone()), ) - .route("/config", get(config_redirect)) - .route("/config", post(config_redirect)) - .route( - "/api/search", - get(search_api).with_state(Arc::new(context.mam())), - ) - .route( - "/api/search", - post(search_api_post).with_state(context.clone()), - ) - .route( - "/api/torrents/{id}", - get(torrent_api).with_state(context.clone()), - ) - .nest_service( - "/assets", - ServiceBuilder::new() - .layer(middleware::from_fn(set_static_cache_control)) - .service(ServeDir::new("server/assets")), - ); - - #[cfg(debug_assertions)] - let app = app.nest_service( - "/assets/favicon.png", - ServiceBuilder::new() - .layer(middleware::from_fn(set_static_cache_control)) - .service(ServeFile::new("server/assets/favicon_dev.png")), - ); - - #[cfg(debug_assertions)] - let app = app.nest_service( - "/favicon.ico", - ServiceBuilder::new() - .layer(middleware::from_fn(set_static_cache_control)) - .service(ServeFile::new("server/assets/favicon_dev.png")), - ); - - #[cfg(not(debug_assertions))] - let app = app.nest_service( - "/favicon.ico", - ServiceBuilder::new() - .layer(middleware::from_fn(set_static_cache_control)) - .service(ServeFile::new("server/assets/favicon.png")), - ); - - app + .route("/old/config", get(config_redirect)) + .route("/old/config", post(config_redirect)) } async fn config_redirect(uri: OriginalUri) -> Redirect { - let current = uri.path_and_query().map_or("/config", |pq| pq.as_str()); - let target = current.replacen("/config", "/dioxus/config", 1); + let current = uri.path_and_query().map_or("/old/config", |pq| pq.as_str()); + let target = current.replacen("/old/config", "/config", 1); Redirect::to(&target) } @@ -231,15 +164,6 @@ pub trait Page { } } -async fn set_static_cache_control(request: Request, next: Next) -> Response { - let mut response = next.run(request).await; - response.headers_mut().insert( - header::CACHE_CONTROL, - HeaderValue::from_static("must-revalidate"), - ); - response -} - /// ```askama /// {% if values.len() > 5 %} /// [
diff --git a/mlm_web_askama/src/pages/torrent.rs b/mlm_web_askama/src/pages/torrent.rs index 42f9d9d3..fc543552 100644 --- a/mlm_web_askama/src/pages/torrent.rs +++ b/mlm_web_askama/src/pages/torrent.rs @@ -3,9 +3,8 @@ use std::{collections::BTreeSet, ops::Deref, path::PathBuf}; use anyhow::Result; use askama::Template; use axum::{ - body::Body, extract::{OriginalUri, Path, State}, - response::{Html, IntoResponse, Redirect}, + response::{Html, Redirect}, }; use axum_extra::extract::Form; use itertools::Itertools; @@ -24,10 +23,8 @@ use qbit::{ parameters::TorrentState, }; use regex::Regex; -use reqwest::header; use serde::Deserialize; use time::UtcDateTime; -use tokio_util::io::ReaderStream; use crate::{ AppError, Conditional, MaMTorrentsTemplate, Page, TorrentLink, flag_icons, @@ -42,59 +39,12 @@ use mlm_core::{ audiobookshelf::{Abs, LibraryItemMinified}, cleaner::clean_torrent, linker::{ - find_library, library_dir, map_path, refresh_mam_metadata, refresh_metadata_relink, relink, + find_library, library_dir, refresh_mam_metadata, refresh_metadata_relink, relink, }, qbittorrent::{self, ensure_category_exists}, }; use mlm_db::MetadataSource; -pub async fn torrent_file( - State(context): State, - Path((id, filename)): Path<(String, String)>, -) -> impl IntoResponse { - let config = context.config().await; - let Some(torrent) = context.db().r_transaction()?.get().primary::(id)? else { - return Err(AppError::NotFound); - }; - let Some(path) = (if let (Some(library_path), Some(library_file)) = ( - &torrent.library_path, - torrent - .library_files - .iter() - .find(|f| f.to_string_lossy() == filename), - ) { - Some(library_path.join(library_file)) - } else if let Some((torrent, qbit, qbit_config)) = - qbittorrent::get_torrent(&config, &torrent.id).await? - { - qbit.files(&torrent.hash, None) - .await? - .into_iter() - .find(|f| f.name == filename) - .map(|file| map_path(&qbit_config.path_mapping, &torrent.save_path).join(&file.name)) - } else { - None - }) else { - return Err(AppError::NotFound); - }; - let file = match tokio::fs::File::open(path).await { - Ok(file) => file, - Err(_) => return Err(AppError::NotFound), - }; - let stream = ReaderStream::new(file); - let body = Body::from_stream(stream); - - let headers = [ - (header::CONTENT_TYPE, "text/toml; charset=utf-8".to_string()), - ( - header::CONTENT_DISPOSITION, - format!("attachment; filename=\"{}\"", filename), - ), - ]; - - Ok((headers, body)) -} - pub async fn torrent_page( State(context): State, Path(id_or_mam_id): Path, @@ -535,7 +485,7 @@ impl TorrentPageTemplate { impl Page for TorrentPageTemplate { fn item_path(&self) -> &'static str { - "/torrents" + "/old/torrents" } } @@ -549,7 +499,7 @@ struct TorrentMamPageTemplate { impl Page for TorrentMamPageTemplate { fn item_path(&self) -> &'static str { - "/torrents" + "/old/torrents" } } diff --git a/mlm_web_askama/src/pages/torrent_edit.rs b/mlm_web_askama/src/pages/torrent_edit.rs index c156974b..2a2de265 100644 --- a/mlm_web_askama/src/pages/torrent_edit.rs +++ b/mlm_web_askama/src/pages/torrent_edit.rs @@ -113,7 +113,7 @@ pub async fn torrent_edit_page_post( ) .await?; - Ok(Redirect::to(&format!("/torrents/{}", hash))) + Ok(Redirect::to(&format!("/old/torrents/{}", hash))) } #[derive(Debug, Deserialize)] @@ -158,6 +158,6 @@ impl TorrentPageTemplate { impl Page for TorrentPageTemplate { fn item_path(&self) -> &'static str { - "/torrents" + "/old/torrents" } } diff --git a/mlm_web_askama/templates/base.html b/mlm_web_askama/templates/base.html index 8443c204..e27680f0 100644 --- a/mlm_web_askama/templates/base.html +++ b/mlm_web_askama/templates/base.html @@ -11,14 +11,14 @@
diff --git a/mlm_web_askama/templates/pages/duplicate.html b/mlm_web_askama/templates/pages/duplicate.html index 6e71cd86..6dee4caf 100644 --- a/mlm_web_askama/templates/pages/duplicate.html +++ b/mlm_web_askama/templates/pages/duplicate.html @@ -57,7 +57,7 @@

Duplicate Torrents

{{ self::time(duplicate_of.created_at) }}
- open + open {% if let Some(mam_id) = duplicate_of.mam_id %}MaM{% endif %} {% if let (Some(abs_url), Some(abs_id)) = (abs_url.as_ref(), duplicate_of.meta.ids.get(ids::ABS)) %} ABS diff --git a/mlm_web_askama/templates/pages/errors.html b/mlm_web_askama/templates/pages/errors.html index 77c7ac09..a4ee3aa6 100644 --- a/mlm_web_askama/templates/pages/errors.html +++ b/mlm_web_askama/templates/pages/errors.html @@ -37,7 +37,7 @@

Torrent Errors

{% match error.meta %} {% when Some(meta) %} {% if let Some(mam_id) = meta.mam_id() %} - open + open MaM {% endif %} {% when None %} diff --git a/mlm_web_askama/templates/pages/list.html b/mlm_web_askama/templates/pages/list.html index 91e95df4..8249ccdf 100644 --- a/mlm_web_askama/templates/pages/list.html +++ b/mlm_web_askama/templates/pages/list.html @@ -47,13 +47,13 @@

{{ item.title }}

{% if let Some(torrent) = item.audio_torrent %} {% match torrent.status %} {% when TorrentStatus::Selected %} - Downloaded audiobook torrent at {{ self::time(torrent.at) }}
+ Downloaded audiobook torrent at {{ self::time(torrent.at) }}
{% when TorrentStatus::Wanted %} - Suggest wedge audiobook torrent at {{ self::time(torrent.at) }}
+ Suggest wedge audiobook torrent at {{ self::time(torrent.at) }}
{% when TorrentStatus::NotWanted %} - Skipped audiobook torrent as an ebook was found at {{ self::time(torrent.at) }}
+ Skipped audiobook torrent as an ebook was found at {{ self::time(torrent.at) }}
{% when TorrentStatus::Existing %} - Found matching audiobook torrent in library at {{ self::time(torrent.at) }}
+ Found matching audiobook torrent in library at {{ self::time(torrent.at) }}
{% endmatch %} {% elif item.want_audio() %} Audiobook missing
@@ -61,13 +61,13 @@

{{ item.title }}

{% if let Some(torrent) = item.ebook_torrent %} {% match torrent.status %} {% when TorrentStatus::Selected %} - Downloaded ebook torrent at {{ self::time(torrent.at) }}
+ Downloaded ebook torrent at {{ self::time(torrent.at) }}
{% when TorrentStatus::Wanted %} - Suggest wedge ebook torrent at {{ self::time(torrent.at) }}
+ Suggest wedge ebook torrent at {{ self::time(torrent.at) }}
{% when TorrentStatus::NotWanted %} - Skipped ebook torrent as an ebook was found at {{ self::time(torrent.at) }}
+ Skipped ebook torrent as an ebook was found at {{ self::time(torrent.at) }}
{% when TorrentStatus::Existing %} - Found matching ebook torrent in library at {{ self::time(torrent.at) }}
+ Found matching ebook torrent in library at {{ self::time(torrent.at) }}
{% endmatch %} {% elif item.want_ebook() %} Ebook missing
diff --git a/mlm_web_askama/templates/pages/lists.html b/mlm_web_askama/templates/pages/lists.html index aed03a71..57c84017 100644 --- a/mlm_web_askama/templates/pages/lists.html +++ b/mlm_web_askama/templates/pages/lists.html @@ -8,7 +8,7 @@

Goodreads Lists

{% for (config, list) in lists %}
-

{{ config.name.as_deref().unwrap_or(list.title) }}

+

{{ config.name.as_deref().unwrap_or(list.title) }}

Last updated: {% if let Some(updated_at) = list.updated_at %}{{ self::time(updated_at) }}{% else %}never{% endif %}
{% endfor %} @@ -17,7 +17,7 @@

Inactive Lists

Lists that have been removed from the config but are still in the database. They won't be refreshed or have books searched for at MaM.

{% for list in inactive_lists %}
-

{{ list.title }}

+

{{ list.title }}

Last updated: {% if let Some(updated_at) = list.updated_at %}{{ self::time(updated_at) }}{% else %}never{% endif %}
{% endfor %} diff --git a/mlm_web_askama/templates/pages/replaced.html b/mlm_web_askama/templates/pages/replaced.html index 6d33ab79..55d542a8 100644 --- a/mlm_web_askama/templates/pages/replaced.html +++ b/mlm_web_askama/templates/pages/replaced.html @@ -96,7 +96,7 @@

Replaced Torrents

{{ self::time(torrent.created_at) }}
- open + open {% if let Some(mam_id) = torrent.mam_id %}MaM{% endif %} {% if let (Some(abs_url), Some(abs_id)) = (abs_url.as_ref(), torrent.meta.ids.get(ids::ABS)) %} ABS @@ -129,7 +129,7 @@

Replaced Torrents

{{ self::time(replacement.created_at) }}
- open + open {% if let Some(mam_id) = replacement.mam_id %}MaM{% endif %} {% if let (Some(abs_url), Some(abs_id)) = (abs_url.as_ref(), replacement.meta.ids.get(ids::ABS)) %} ABS diff --git a/mlm_web_askama/templates/pages/torrent.html b/mlm_web_askama/templates/pages/torrent.html index 1d15fa92..bd74ff78 100644 --- a/mlm_web_askama/templates/pages/torrent.html +++ b/mlm_web_askama/templates/pages/torrent.html @@ -14,14 +14,14 @@ {% block content %}

{{ torrent.meta.title }}

- edit + edit
{% if let Some((edition, _)) = torrent.meta.edition %} {{ edition }} {% endif %} {% if let Some(torrent) = replacement_torrent %}
-

Replaced with: {{ torrent.meta.title }}

+

Replaced with: {{ torrent.meta.title }}

{% else if torrent.replaced_with.is_some() %}
diff --git a/mlm_web_askama/templates/pages/torrents.html b/mlm_web_askama/templates/pages/torrents.html index 44ec193c..d027fa83 100644 --- a/mlm_web_askama/templates/pages/torrents.html +++ b/mlm_web_askama/templates/pages/torrents.html @@ -230,7 +230,7 @@

Torrents

{% if let Some(uploaded_at) = torrent.meta.uploaded_at %}{{ self::time(uploaded_at) }}{% endif %}
{% endif %}
- open + open {% if let Some(mam_id) = torrent.mam_id %}MaM{% endif %} {% if let (Some(abs_url), Some(abs_id)) = (abs_url.as_ref(), torrent.meta.ids.get(ids::ABS)) %} ABS diff --git a/mlm_web_askama/templates/partials/mam_torrents.html b/mlm_web_askama/templates/partials/mam_torrents.html index 35545766..53722a08 100644 --- a/mlm_web_askama/templates/partials/mam_torrents.html +++ b/mlm_web_askama/templates/partials/mam_torrents.html @@ -20,18 +20,18 @@ {% if mam_torrent.lang_code != "ENG" %} [{{ mam_torrent.lang_code }}] {% endif %} - {{ meta.title }}{% if let Some((edition, _)) = meta.edition %} {{ edition }}{% endif %}
- by {% for author in meta.authors %}{{ author }}{% if !loop.last %}, {% endif %}{% endfor %}
+ {{ meta.title }}{% if let Some((edition, _)) = meta.edition %} {{ edition }}{% endif %}
+ by {% for author in meta.authors %}{{ author }}{% if !loop.last %}, {% endif %}{% endfor %}
{% if !meta.series.is_empty() %} series {% for series in meta.series %} - {{ series.name }} + {{ series.name }} {% if !series.entries.0.is_empty() %}#{{ series.entries }}{% endif %} {% endfor %}
{% endif %} {% if !meta.narrators.is_empty() %} - narrated by {% for narrator in meta.narrators %}{{ narrator }}{% if !loop.last %}, {% endif %}{% endfor %}
+ narrated by {% for narrator in meta.narrators %}{{ narrator }}{% if !loop.last %}, {% endif %}{% endfor %}
{% endif %} {{ mam_torrent.tags }}
diff --git a/mlm_web_dioxus/src/app.rs b/mlm_web_dioxus/src/app.rs index b962ee35..bd22d352 100644 --- a/mlm_web_dioxus/src/app.rs +++ b/mlm_web_dioxus/src/app.rs @@ -23,46 +23,46 @@ pub enum Route { #[route("/")] HomePage {}, - #[route("/dioxus/events")] + #[route("/events")] EventsPage {}, - #[route("/dioxus/events/:..segments")] + #[route("/events/:..segments")] EventsWithQuery { segments: Vec }, - #[route("/dioxus/errors")] + #[route("/errors")] ErrorsPage {}, - #[route("/dioxus/selected")] + #[route("/selected")] SelectedPage {}, - #[route("/dioxus/replaced")] + #[route("/replaced")] ReplacedPage {}, - #[route("/dioxus/duplicate")] + #[route("/duplicate")] DuplicatePage {}, - #[route("/dioxus/torrents")] + #[route("/torrents")] TorrentsPage {}, - #[route("/dioxus/torrents/:id")] + #[route("/torrents/:id")] TorrentDetailPage { id: String }, - #[route("/dioxus/torrents/:id/edit")] + #[route("/torrents/:id/edit")] TorrentEditPage { id: String }, - #[route("/dioxus/torrents/:..segments")] + #[route("/torrents/:..segments")] TorrentsWithQuery { segments: Vec }, - #[route("/dioxus/search")] + #[route("/search")] SearchPage {}, - #[route("/dioxus/lists")] + #[route("/lists")] ListsPage {}, - #[route("/dioxus/lists/:id")] + #[route("/lists/:id")] ListPage { id: String }, - #[route("/dioxus/config")] + #[route("/config")] ConfigPage {}, } diff --git a/mlm_web_dioxus/src/components/filter_link.rs b/mlm_web_dioxus/src/components/filter_link.rs index 26de22f2..7d0f8744 100644 --- a/mlm_web_dioxus/src/components/filter_link.rs +++ b/mlm_web_dioxus/src/components/filter_link.rs @@ -36,7 +36,7 @@ pub fn TorrentTitleLink( rsx! { a { class: "link", - href: "/dioxus/torrents/{detail_id}", + href: "/torrents/{detail_id}", onclick: move |ev: MouseEvent| { if ev.modifiers().alt() { ev.prevent_default(); diff --git a/mlm_web_dioxus/src/components/search_row.rs b/mlm_web_dioxus/src/components/search_row.rs index 6a118766..3a42a889 100644 --- a/mlm_web_dioxus/src/components/search_row.rs +++ b/mlm_web_dioxus/src/components/search_row.rs @@ -22,7 +22,7 @@ pub fn search_filter_href(prefix: &str, value: &str, sort: &str) -> String { .map(|(k, v)| format!("{}={}", urlencoding::encode(k), urlencoding::encode(v))) .collect::>() .join("&"); - format!("/dioxus/search?{query}") + format!("/search?{query}") } #[derive(Clone, PartialEq)] diff --git a/mlm_web_dioxus/src/errors/components.rs b/mlm_web_dioxus/src/errors/components.rs index b6d705be..612394a2 100644 --- a/mlm_web_dioxus/src/errors/components.rs +++ b/mlm_web_dioxus/src/errors/components.rs @@ -294,7 +294,7 @@ pub fn ErrorsPage() -> Element { div { "{error.created_at}" } div { if let Some(mam_id) = error.mam_id { - a { href: "/dioxus/torrents/{mam_id}", "open" } + a { href: "/torrents/{mam_id}", "open" } a { href: "https://www.myanonamouse.net/t/{mam_id}", target: "_blank", diff --git a/mlm_web_dioxus/src/events/components.rs b/mlm_web_dioxus/src/events/components.rs index 0de5b61a..ff59ebb1 100644 --- a/mlm_web_dioxus/src/events/components.rs +++ b/mlm_web_dioxus/src/events/components.rs @@ -318,7 +318,7 @@ pub fn EventContent( let render_torrent_link = |id: Option, title: Option| { if let (Some(id), Some(title)) = (id, title) { - rsx! { a { href: "/dioxus/torrents/{id}", "{title}" } } + rsx! { a { href: "/torrents/{id}", "{title}" } } } else { rsx! { "" } } diff --git a/mlm_web_dioxus/src/list.rs b/mlm_web_dioxus/src/list.rs index 60c3d2c9..1ab6fdcc 100644 --- a/mlm_web_dioxus/src/list.rs +++ b/mlm_web_dioxus/src/list.rs @@ -71,7 +71,7 @@ fn matches_show_filter(item: &ListItemDto, show: Option<&str>) -> bool { fn render_list_torrent_link(torrent: &ListItemTorrentDto) -> Element { if let Some(id) = &torrent.id { - rsx! { a { href: "/dioxus/torrents/{id}", target: "_blank", rel: "noopener noreferrer", "torrent" } } + rsx! { a { href: "/torrents/{id}", target: "_blank", rel: "noopener noreferrer", "torrent" } } } else { rsx! { "torrent" } } diff --git a/mlm_web_dioxus/src/sse.rs b/mlm_web_dioxus/src/sse.rs index 984484ef..c49cade5 100644 --- a/mlm_web_dioxus/src/sse.rs +++ b/mlm_web_dioxus/src/sse.rs @@ -78,11 +78,11 @@ pub fn setup_sse() { }); } - connect_sse("/dioxus-stats-updates", trigger_stats_update); - connect_sse("/dioxus-events-updates", trigger_events_update); - connect_sse("/dioxus-selected-updates", trigger_selected_update); - connect_sse("/dioxus-errors-updates", trigger_errors_update); - connect_sse_data("/dioxus-qbit-progress", |data| { + connect_sse("/stats-updates", trigger_stats_update); + connect_sse("/events-updates", trigger_events_update); + connect_sse("/selected-updates", trigger_selected_update); + connect_sse("/errors-updates", trigger_errors_update); + connect_sse_data("/qbit-progress", |data| { if let Ok(progress) = serde_json::from_str::>(&data) { update_qbit_progress(progress); } diff --git a/mlm_web_dioxus/src/ssr.rs b/mlm_web_dioxus/src/ssr.rs index d3ea2cd2..5b151344 100644 --- a/mlm_web_dioxus/src/ssr.rs +++ b/mlm_web_dioxus/src/ssr.rs @@ -124,11 +124,11 @@ async fn fetch_qbit_progress(context: &Context) -> Option { pub fn router(ctx: Context) -> Router<()> { Router::new() - .route("/dioxus-stats-updates", get(dioxus_stats_updates)) - .route("/dioxus-events-updates", get(dioxus_events_updates)) - .route("/dioxus-selected-updates", get(dioxus_selected_updates)) - .route("/dioxus-errors-updates", get(dioxus_errors_updates)) - .route("/dioxus-qbit-progress", get(dioxus_qbit_progress)) + .route("/stats-updates", get(dioxus_stats_updates)) + .route("/events-updates", get(dioxus_events_updates)) + .route("/selected-updates", get(dioxus_selected_updates)) + .route("/errors-updates", get(dioxus_errors_updates)) + .route("/qbit-progress", get(dioxus_qbit_progress)) .serve_api_application(ServeConfig::builder(), root) .layer(Extension(ctx)) } diff --git a/mlm_web_dioxus/src/torrent_detail/components.rs b/mlm_web_dioxus/src/torrent_detail/components.rs index 9aacb1b2..c205a319 100644 --- a/mlm_web_dioxus/src/torrent_detail/components.rs +++ b/mlm_web_dioxus/src/torrent_detail/components.rs @@ -292,7 +292,7 @@ fn TorrentDetailContent( if let Some(replacement) = replacement_torrent { div { class: "warn", strong { "Replaced with: " } - a { href: "/dioxus/torrents/{replacement.id}", "{replacement.title}" } + a { href: "/torrents/{replacement.id}", "{replacement.title}" } } } if replacement_missing { @@ -326,7 +326,7 @@ fn TorrentDetailContent( div { style: "display:flex; flex-wrap:wrap; gap:0.5em; margin:0.6em 0;", a { class: "btn", - href: "/dioxus/torrents/{torrent.id}/edit", + href: "/torrents/{torrent.id}/edit", "Edit Metadata" } if let Some(abs_url) = abs_item_url { diff --git a/mlm_web_dioxus/src/torrent_edit.rs b/mlm_web_dioxus/src/torrent_edit.rs index a89f54fd..262cc591 100644 --- a/mlm_web_dioxus/src/torrent_edit.rs +++ b/mlm_web_dioxus/src/torrent_edit.rs @@ -783,7 +783,7 @@ pub fn TorrentEditPage(id: String) -> Element { div { class: "row", button { r#type: "submit", class: "btn", "Save" } - a { class: "btn", href: "/dioxus/torrents/{form.torrent_id}", "Back to Torrent" } + a { class: "btn", href: "/torrents/{form.torrent_id}", "Back to Torrent" } } } } else if let Some(Err(e)) = &*data_res.value().read() { diff --git a/server/Cargo.toml b/server/Cargo.toml index bdf5ae4a..700c0a69 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -19,6 +19,7 @@ figment = { version = "0.10", features = ["toml", "env"] } mlm_core = { path = "../mlm_core" } mlm_db = { path = "../mlm_db" } mlm_mam = { path = "../mlm_mam" } +mlm_web_api = { path = "../mlm_web_api" } mlm_web_askama = { path = "../mlm_web_askama" } mlm_web_dioxus = { path = "../mlm_web_dioxus", features = ["server"] } native_db = { git = "https://github.com/StirlingMouse/native_db.git", branch = "0.8.x" } @@ -41,6 +42,7 @@ tracing-subscriber = { version = "0.3", features = [ "tracing-log", ] } tracing-panic = "0.1.2" +tower-http = { version = "0.6.6", features = ["fs"] } [target.'cfg(windows)'.dependencies] open = "5.3.2" diff --git a/server/src/main.rs b/server/src/main.rs index 0e274814..8e1efe3d 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -32,8 +32,10 @@ use axum::{ response::Response, }; use mlm_core::{Config, Stats, metadata::MetadataService}; -use mlm_web_askama::{ServeDir, router as askama_router}; +use mlm_web_api::router as api_router; +use mlm_web_askama::router as askama_router; use mlm_web_dioxus::ssr::router as dioxus_router; +use tower_http::services::ServeDir; #[cfg(target_family = "windows")] use mlm::windows; @@ -245,6 +247,7 @@ async fn app_main() -> Result<()> { let app = wasm_router .merge(dioxus_router(context.clone())) + .merge(api_router(context.clone(), dioxus_public_path.clone())) .merge(askama_router(context.clone())); let listener = tokio::net::TcpListener::bind((web_host, web_port)).await?;