diff --git a/apps/api/src/books.rs b/apps/api/src/books.rs index 2c392c6..7912d9b 100644 --- a/apps/api/src/books.rs +++ b/apps/api/src/books.rs @@ -262,7 +262,7 @@ pub async fn list_books( )?; let total: i64 = count_row.get(0); - let mut items: Vec = rows + let items: Vec = rows .iter() .map(|row| { let thumbnail_path: Option = row.get("thumbnail_path"); @@ -288,7 +288,7 @@ pub async fn list_books( .collect(); Ok(Json(BooksPage { - items: std::mem::take(&mut items), + items, total, page, limit, @@ -369,23 +369,8 @@ pub async fn get_book( // ─── Helpers ────────────────────────────────────────────────────────────────── -pub(crate) fn remap_libraries_path(path: &str) -> String { - if let Ok(root) = std::env::var("LIBRARIES_ROOT_PATH") { - if path.starts_with("/libraries/") { - return path.replacen("/libraries", &root, 1); - } - } - path.to_string() -} - -fn unmap_libraries_path(path: &str) -> String { - if let Ok(root) = std::env::var("LIBRARIES_ROOT_PATH") { - if path.starts_with(&root) { - return path.replacen(&root, "/libraries", 1); - } - } - path.to_string() -} +pub(crate) use stripstream_core::paths::remap_libraries_path; +pub(crate) use stripstream_core::paths::unmap_libraries_path; // ─── Convert CBR → CBZ ─────────────────────────────────────────────────────── diff --git a/apps/api/src/libraries.rs b/apps/api/src/libraries.rs index 96d0677..0f7a24d 100644 --- a/apps/api/src/libraries.rs +++ b/apps/api/src/libraries.rs @@ -314,59 +314,39 @@ pub async fn update_monitoring( AxumPath(library_id): AxumPath, Json(input): Json, ) -> Result, ApiError> { + use stripstream_core::schedule::{validate_schedule_mode, mode_to_interval_minutes}; + // Validate scan_mode - let valid_modes = ["manual", "hourly", "daily", "weekly"]; - if !valid_modes.contains(&input.scan_mode.as_str()) { - return Err(ApiError::bad_request("scan_mode must be one of: manual, hourly, daily, weekly")); - } + validate_schedule_mode(&input.scan_mode) + .map_err(|e| ApiError::bad_request(format!("scan_mode {e}")))?; // Validate metadata_refresh_mode let metadata_refresh_mode = input.metadata_refresh_mode.as_deref().unwrap_or("manual"); - if !valid_modes.contains(&metadata_refresh_mode) { - return Err(ApiError::bad_request("metadata_refresh_mode must be one of: manual, hourly, daily, weekly")); - } + validate_schedule_mode(metadata_refresh_mode) + .map_err(|e| ApiError::bad_request(format!("metadata_refresh_mode {e}")))?; // Validate download_detection_mode let download_detection_mode = input.download_detection_mode.as_deref().unwrap_or("manual"); - if !valid_modes.contains(&download_detection_mode) { - return Err(ApiError::bad_request("download_detection_mode must be one of: manual, hourly, daily, weekly")); - } + validate_schedule_mode(download_detection_mode) + .map_err(|e| ApiError::bad_request(format!("download_detection_mode {e}")))?; // Calculate next_scan_at if monitoring is enabled let next_scan_at = if input.monitor_enabled { - let interval_minutes = match input.scan_mode.as_str() { - "hourly" => 60, - "daily" => 1440, - "weekly" => 10080, - _ => 1440, - }; - Some(chrono::Utc::now() + chrono::Duration::minutes(interval_minutes)) + Some(chrono::Utc::now() + chrono::Duration::minutes(mode_to_interval_minutes(&input.scan_mode))) } else { None }; // Calculate next_metadata_refresh_at let next_metadata_refresh_at = if metadata_refresh_mode != "manual" { - let interval_minutes = match metadata_refresh_mode { - "hourly" => 60, - "daily" => 1440, - "weekly" => 10080, - _ => 1440, - }; - Some(chrono::Utc::now() + chrono::Duration::minutes(interval_minutes)) + Some(chrono::Utc::now() + chrono::Duration::minutes(mode_to_interval_minutes(metadata_refresh_mode))) } else { None }; // Calculate next_download_detection_at let next_download_detection_at = if download_detection_mode != "manual" { - let interval_minutes = match download_detection_mode { - "hourly" => 60, - "daily" => 1440, - "weekly" => 10080, - _ => 1440, - }; - Some(chrono::Utc::now() + chrono::Duration::minutes(interval_minutes)) + Some(chrono::Utc::now() + chrono::Duration::minutes(mode_to_interval_minutes(download_detection_mode))) } else { None }; @@ -553,20 +533,14 @@ pub async fn update_reading_status_provider( ) -> Result, ApiError> { let provider = input.reading_status_provider.as_deref().filter(|s| !s.is_empty()); - let valid_modes = ["manual", "hourly", "daily", "weekly"]; + use stripstream_core::schedule::{validate_schedule_mode, mode_to_interval_minutes}; + let push_mode = input.reading_status_push_mode.as_deref().unwrap_or("manual"); - if !valid_modes.contains(&push_mode) { - return Err(ApiError::bad_request("reading_status_push_mode must be one of: manual, hourly, daily, weekly")); - } + validate_schedule_mode(push_mode) + .map_err(|e| ApiError::bad_request(format!("reading_status_push_mode {e}")))?; let next_push_at = if push_mode != "manual" { - let interval_minutes: i64 = match push_mode { - "hourly" => 60, - "daily" => 1440, - "weekly" => 10080, - _ => 1440, - }; - Some(chrono::Utc::now() + chrono::Duration::minutes(interval_minutes)) + Some(chrono::Utc::now() + chrono::Duration::minutes(mode_to_interval_minutes(push_mode))) } else { None }; diff --git a/apps/api/src/metadata.rs b/apps/api/src/metadata.rs index 3db9d85..1a533ea 100644 --- a/apps/api/src/metadata.rs +++ b/apps/api/src/metadata.rs @@ -38,7 +38,6 @@ pub struct SeriesCandidateDto { } #[derive(Deserialize, ToSchema)] -#[allow(dead_code)] pub struct MetadataMatchRequest { pub library_id: String, pub series_name: String, @@ -46,6 +45,7 @@ pub struct MetadataMatchRequest { pub external_id: String, pub external_url: Option, pub confidence: Option, + #[allow(dead_code)] pub title: String, pub metadata_json: serde_json::Value, pub total_volumes: Option, diff --git a/apps/api/src/pages.rs b/apps/api/src/pages.rs index ef7b06b..eda968c 100644 --- a/apps/api/src/pages.rs +++ b/apps/api/src/pages.rs @@ -21,14 +21,7 @@ use uuid::Uuid; use crate::{error::ApiError, state::AppState}; -fn remap_libraries_path(path: &str) -> String { - if let Ok(root) = std::env::var("LIBRARIES_ROOT_PATH") { - if path.starts_with("/libraries/") { - return path.replacen("/libraries", &root, 1); - } - } - path.to_string() -} +use stripstream_core::paths::remap_libraries_path; fn parse_filter(s: &str) -> image::imageops::FilterType { match s { diff --git a/apps/api/src/prowlarr.rs b/apps/api/src/prowlarr.rs index 22f8eed..0efe17d 100644 --- a/apps/api/src/prowlarr.rs +++ b/apps/api/src/prowlarr.rs @@ -289,9 +289,7 @@ fn extract_volumes_from_title(title: &str) -> Vec { j += 1; } if j > 0 && j < chars.len() { - let valid_sep = chars[j] == '.' - || chars[j] == ' ' - || (j + 2 < chars.len() && chars[j] == ' ' && chars[j + 1] == '-'); + let valid_sep = chars[j] == '.' || chars[j] == ' '; if valid_sep { let num_str: String = chars[..j].iter().collect(); if let Ok(num) = num_str.parse::() { diff --git a/apps/api/src/torrent_import.rs b/apps/api/src/torrent_import.rs index 6307c56..f10dec4 100644 --- a/apps/api/src/torrent_import.rs +++ b/apps/api/src/torrent_import.rs @@ -1024,23 +1024,7 @@ fn remap_downloads_path(path: &str) -> String { path.to_string() } -fn remap_libraries_path(path: &str) -> String { - if let Ok(root) = std::env::var("LIBRARIES_ROOT_PATH") { - if path.starts_with("/libraries/") { - return path.replacen("/libraries", &root, 1); - } - } - path.to_string() -} - -fn unmap_libraries_path(path: &str) -> String { - if let Ok(root) = std::env::var("LIBRARIES_ROOT_PATH") { - if path.starts_with(&root) { - return path.replacen(&root, "/libraries", 1); - } - } - path.to_string() -} +use stripstream_core::paths::{remap_libraries_path, unmap_libraries_path}; // ─── Naming helpers ─────────────────────────────────────────────────────────── diff --git a/apps/indexer/src/scheduler.rs b/apps/indexer/src/scheduler.rs index 65820ac..e4b99d2 100644 --- a/apps/indexer/src/scheduler.rs +++ b/apps/indexer/src/scheduler.rs @@ -1,5 +1,6 @@ use anyhow::Result; use sqlx::{PgPool, Row}; +use stripstream_core::schedule::mode_to_interval_minutes; use tracing::info; use uuid::Uuid; @@ -45,12 +46,7 @@ pub async fn check_and_schedule_auto_scans(pool: &PgPool) -> Result<()> { .await?; // Update next_scan_at - let interval_minutes = match scan_mode.as_str() { - "hourly" => 60, - "daily" => 1440, - "weekly" => 10080, - _ => 1440, // default daily - }; + let interval_minutes = mode_to_interval_minutes(&scan_mode); sqlx::query( "UPDATE libraries SET last_scan_at = NOW(), next_scan_at = NOW() + INTERVAL '1 minute' * $2 WHERE id = $1" @@ -107,12 +103,7 @@ pub async fn check_and_schedule_reading_status_push(pool: &PgPool) -> Result<()> .execute(pool) .await?; - let interval_minutes: i64 = match push_mode.as_str() { - "hourly" => 60, - "daily" => 1440, - "weekly" => 10080, - _ => 1440, - }; + let interval_minutes = mode_to_interval_minutes(&push_mode); sqlx::query( "UPDATE libraries SET last_reading_status_push_at = NOW(), next_reading_status_push_at = NOW() + INTERVAL '1 minute' * $2 WHERE id = $1" @@ -176,12 +167,7 @@ pub async fn check_and_schedule_download_detection(pool: &PgPool) -> Result<()> .execute(pool) .await?; - let interval_minutes: i64 = match detection_mode.as_str() { - "hourly" => 60, - "daily" => 1440, - "weekly" => 10080, - _ => 1440, - }; + let interval_minutes = mode_to_interval_minutes(&detection_mode); sqlx::query( "UPDATE libraries SET last_download_detection_at = NOW(), next_download_detection_at = NOW() + INTERVAL '1 minute' * $2 WHERE id = $1" @@ -238,12 +224,7 @@ pub async fn check_and_schedule_metadata_refreshes(pool: &PgPool) -> Result<()> .execute(pool) .await?; - let interval_minutes = match refresh_mode.as_str() { - "hourly" => 60, - "daily" => 1440, - "weekly" => 10080, - _ => 1440, - }; + let interval_minutes = mode_to_interval_minutes(&refresh_mode); sqlx::query( "UPDATE libraries SET last_metadata_refresh_at = NOW(), next_metadata_refresh_at = NOW() + INTERVAL '1 minute' * $2 WHERE id = $1" diff --git a/apps/indexer/src/utils.rs b/apps/indexer/src/utils.rs index ffac194..5cea126 100644 --- a/apps/indexer/src/utils.rs +++ b/apps/indexer/src/utils.rs @@ -5,23 +5,8 @@ use sha2::{Digest, Sha256}; use std::path::Path; use chrono::Utc; -pub fn remap_libraries_path(path: &str) -> String { - if let Ok(root) = std::env::var("LIBRARIES_ROOT_PATH") { - if path.starts_with("/libraries/") { - return path.replacen("/libraries", &root, 1); - } - } - path.to_string() -} - -pub fn unmap_libraries_path(path: &str) -> String { - if let Ok(root) = std::env::var("LIBRARIES_ROOT_PATH") { - if path.starts_with(&root) { - return path.replacen(&root, "/libraries", 1); - } - } - path.to_string() -} +pub use stripstream_core::paths::remap_libraries_path; +pub use stripstream_core::paths::unmap_libraries_path; pub fn compute_fingerprint(path: &Path, size: u64, mtime: &DateTime) -> Result { // Optimized: only use size + mtime + first bytes of filename for fast fingerprinting diff --git a/crates/core/src/config.rs b/crates/core/src/config.rs index d52364b..f06b944 100644 --- a/crates/core/src/config.rs +++ b/crates/core/src/config.rs @@ -50,38 +50,33 @@ impl Default for ThumbnailConfig { } } +/// Parse an environment variable with a fallback default value. +pub fn env_or(key: &str, default: T) -> T { + std::env::var(key) + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(default) +} + +/// Parse an environment variable as a String with a fallback default. +pub fn env_string_or(key: &str, default: &str) -> String { + std::env::var(key).unwrap_or_else(|_| default.to_string()) +} + impl IndexerConfig { pub fn from_env() -> Result { - let thumbnail_config = ThumbnailConfig { - enabled: std::env::var("THUMBNAIL_ENABLED") - .ok() - .and_then(|v| v.parse::().ok()) - .unwrap_or(true), - width: std::env::var("THUMBNAIL_WIDTH") - .ok() - .and_then(|v| v.parse::().ok()) - .unwrap_or(300), - height: std::env::var("THUMBNAIL_HEIGHT") - .ok() - .and_then(|v| v.parse::().ok()) - .unwrap_or(400), - quality: std::env::var("THUMBNAIL_QUALITY") - .ok() - .and_then(|v| v.parse::().ok()) - .unwrap_or(80), - format: std::env::var("THUMBNAIL_FORMAT").unwrap_or_else(|_| "webp".to_string()), - directory: std::env::var("THUMBNAIL_DIRECTORY") - .unwrap_or_else(|_| "/data/thumbnails".to_string()), - }; + let mut thumbnail_config = ThumbnailConfig::default(); + thumbnail_config.enabled = env_or("THUMBNAIL_ENABLED", thumbnail_config.enabled); + thumbnail_config.width = env_or("THUMBNAIL_WIDTH", thumbnail_config.width); + thumbnail_config.height = env_or("THUMBNAIL_HEIGHT", thumbnail_config.height); + thumbnail_config.quality = env_or("THUMBNAIL_QUALITY", thumbnail_config.quality); + thumbnail_config.format = env_string_or("THUMBNAIL_FORMAT", &thumbnail_config.format); + thumbnail_config.directory = env_string_or("THUMBNAIL_DIRECTORY", &thumbnail_config.directory); Ok(Self { - listen_addr: std::env::var("INDEXER_LISTEN_ADDR") - .unwrap_or_else(|_| "0.0.0.0:7081".to_string()), + listen_addr: env_string_or("INDEXER_LISTEN_ADDR", "0.0.0.0:7081"), database_url: std::env::var("DATABASE_URL").context("DATABASE_URL is required")?, - scan_interval_seconds: std::env::var("INDEXER_SCAN_INTERVAL_SECONDS") - .ok() - .and_then(|v| v.parse::().ok()) - .unwrap_or(5), + scan_interval_seconds: env_or("INDEXER_SCAN_INTERVAL_SECONDS", 5), thumbnail_config, }) } diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index ef68c36..4ed897d 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -1 +1,3 @@ pub mod config; +pub mod paths; +pub mod schedule; diff --git a/crates/core/src/paths.rs b/crates/core/src/paths.rs new file mode 100644 index 0000000..29dafc4 --- /dev/null +++ b/crates/core/src/paths.rs @@ -0,0 +1,93 @@ +/// Remap a DB path (starting with `/libraries/`) to the local filesystem path +/// using the `LIBRARIES_ROOT_PATH` environment variable. +pub fn remap_libraries_path(path: &str) -> String { + if let Ok(root) = std::env::var("LIBRARIES_ROOT_PATH") { + if path.starts_with("/libraries/") { + return path.replacen("/libraries", &root, 1); + } + } + path.to_string() +} + +/// Convert a local filesystem path back to a DB path (starting with `/libraries/`). +pub fn unmap_libraries_path(path: &str) -> String { + if let Ok(root) = std::env::var("LIBRARIES_ROOT_PATH") { + if path.starts_with(&root) { + return path.replacen(&root, "/libraries", 1); + } + } + path.to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Mutex; + + // Serialize tests that modify env vars + static ENV_LOCK: Mutex<()> = Mutex::new(()); + + #[test] + fn remap_with_env_set() { + let _lock = ENV_LOCK.lock().unwrap(); + std::env::set_var("LIBRARIES_ROOT_PATH", "/home/user/books"); + assert_eq!( + remap_libraries_path("/libraries/BD/les geants/06. yatho.cbz"), + "/home/user/books/BD/les geants/06. yatho.cbz" + ); + std::env::remove_var("LIBRARIES_ROOT_PATH"); + } + + #[test] + fn remap_without_env() { + let _lock = ENV_LOCK.lock().unwrap(); + std::env::remove_var("LIBRARIES_ROOT_PATH"); + assert_eq!( + remap_libraries_path("/libraries/BD/test.cbz"), + "/libraries/BD/test.cbz" + ); + } + + #[test] + fn remap_non_matching_prefix() { + let _lock = ENV_LOCK.lock().unwrap(); + std::env::set_var("LIBRARIES_ROOT_PATH", "/home/user/books"); + assert_eq!( + remap_libraries_path("/other/path/file.cbz"), + "/other/path/file.cbz" + ); + std::env::remove_var("LIBRARIES_ROOT_PATH"); + } + + #[test] + fn unmap_with_env_set() { + let _lock = ENV_LOCK.lock().unwrap(); + std::env::set_var("LIBRARIES_ROOT_PATH", "/home/user/books"); + assert_eq!( + unmap_libraries_path("/home/user/books/BD/test.cbz"), + "/libraries/BD/test.cbz" + ); + std::env::remove_var("LIBRARIES_ROOT_PATH"); + } + + #[test] + fn unmap_without_env() { + let _lock = ENV_LOCK.lock().unwrap(); + std::env::remove_var("LIBRARIES_ROOT_PATH"); + assert_eq!( + unmap_libraries_path("/home/user/books/BD/test.cbz"), + "/home/user/books/BD/test.cbz" + ); + } + + #[test] + fn roundtrip() { + let _lock = ENV_LOCK.lock().unwrap(); + std::env::set_var("LIBRARIES_ROOT_PATH", "/mnt/data"); + let original = "/libraries/manga/naruto/vol01.cbz"; + let remapped = remap_libraries_path(original); + assert_eq!(remapped, "/mnt/data/manga/naruto/vol01.cbz"); + assert_eq!(unmap_libraries_path(&remapped), original); + std::env::remove_var("LIBRARIES_ROOT_PATH"); + } +} diff --git a/crates/core/src/schedule.rs b/crates/core/src/schedule.rs new file mode 100644 index 0000000..4c28b6f --- /dev/null +++ b/crates/core/src/schedule.rs @@ -0,0 +1,57 @@ +/// Valid schedule modes for library monitoring. +pub const VALID_SCHEDULE_MODES: &[&str] = &["manual", "hourly", "daily", "weekly"]; + +/// Convert a schedule mode to its interval in minutes. +/// Returns the interval for known modes, defaults to daily (1440) for unknown values. +pub fn mode_to_interval_minutes(mode: &str) -> i64 { + match mode { + "hourly" => 60, + "daily" => 1440, + "weekly" => 10080, + _ => 1440, + } +} + +/// Validate that a mode string is one of the accepted schedule modes. +/// Returns an error message if invalid. +pub fn validate_schedule_mode(mode: &str) -> Result<(), &'static str> { + if VALID_SCHEDULE_MODES.contains(&mode) { + Ok(()) + } else { + Err("must be one of: manual, hourly, daily, weekly") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn interval_known_modes() { + assert_eq!(mode_to_interval_minutes("hourly"), 60); + assert_eq!(mode_to_interval_minutes("daily"), 1440); + assert_eq!(mode_to_interval_minutes("weekly"), 10080); + } + + #[test] + fn interval_unknown_defaults_to_daily() { + assert_eq!(mode_to_interval_minutes("manual"), 1440); + assert_eq!(mode_to_interval_minutes("unknown"), 1440); + assert_eq!(mode_to_interval_minutes(""), 1440); + } + + #[test] + fn validate_accepts_valid() { + assert!(validate_schedule_mode("manual").is_ok()); + assert!(validate_schedule_mode("hourly").is_ok()); + assert!(validate_schedule_mode("daily").is_ok()); + assert!(validate_schedule_mode("weekly").is_ok()); + } + + #[test] + fn validate_rejects_invalid() { + assert!(validate_schedule_mode("monthly").is_err()); + assert!(validate_schedule_mode("").is_err()); + assert!(validate_schedule_mode("HOURLY").is_err()); + } +} diff --git a/crates/parsers/src/lib.rs b/crates/parsers/src/lib.rs index 7ea18e7..0fac5c5 100644 --- a/crates/parsers/src/lib.rs +++ b/crates/parsers/src/lib.rs @@ -88,7 +88,7 @@ fn extract_series(path: &Path, library_root: &Path) -> Option { } else if let Ok(relative) = parent.strip_prefix(library_root) { relative } else { - eprintln!( + tracing::warn!( "[PARSER] Cannot determine series: parent '{}' doesn't start with root '{}'", parent.display(), library_root.display()