refactor: Phase A — extraction des helpers partagés et micro-fixes
- Centralise remap_libraries_path/unmap_libraries_path dans crates/core/paths.rs (supprime 4 copies dupliquées dans API + indexer) - Centralise mode_to_interval_minutes/validate_schedule_mode dans crates/core/schedule.rs (remplace 8 match blocks + 4 validations inline) - Ajoute helpers env_or<T>/env_string_or dans config.rs, utilise ThumbnailConfig::default() comme base dans from_env() (élimine la duplication des valeurs par défaut) - Supprime std::mem::take inutile dans books.rs - Cible #[allow(dead_code)] sur le champ plutôt que le struct (metadata.rs) - Remplace eprintln! par tracing::warn! dans parsers - Fix clippy boolean logic bug dans prowlarr.rs 10 nouveaux tests unitaires (paths + schedule) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -262,7 +262,7 @@ pub async fn list_books(
|
|||||||
)?;
|
)?;
|
||||||
let total: i64 = count_row.get(0);
|
let total: i64 = count_row.get(0);
|
||||||
|
|
||||||
let mut items: Vec<BookItem> = rows
|
let items: Vec<BookItem> = rows
|
||||||
.iter()
|
.iter()
|
||||||
.map(|row| {
|
.map(|row| {
|
||||||
let thumbnail_path: Option<String> = row.get("thumbnail_path");
|
let thumbnail_path: Option<String> = row.get("thumbnail_path");
|
||||||
@@ -288,7 +288,7 @@ pub async fn list_books(
|
|||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
Ok(Json(BooksPage {
|
Ok(Json(BooksPage {
|
||||||
items: std::mem::take(&mut items),
|
items,
|
||||||
total,
|
total,
|
||||||
page,
|
page,
|
||||||
limit,
|
limit,
|
||||||
@@ -369,23 +369,8 @@ pub async fn get_book(
|
|||||||
|
|
||||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
pub(crate) fn remap_libraries_path(path: &str) -> String {
|
pub(crate) use stripstream_core::paths::remap_libraries_path;
|
||||||
if let Ok(root) = std::env::var("LIBRARIES_ROOT_PATH") {
|
pub(crate) use stripstream_core::paths::unmap_libraries_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()
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Convert CBR → CBZ ───────────────────────────────────────────────────────
|
// ─── Convert CBR → CBZ ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
@@ -314,59 +314,39 @@ pub async fn update_monitoring(
|
|||||||
AxumPath(library_id): AxumPath<Uuid>,
|
AxumPath(library_id): AxumPath<Uuid>,
|
||||||
Json(input): Json<UpdateMonitoringRequest>,
|
Json(input): Json<UpdateMonitoringRequest>,
|
||||||
) -> Result<Json<LibraryResponse>, ApiError> {
|
) -> Result<Json<LibraryResponse>, ApiError> {
|
||||||
|
use stripstream_core::schedule::{validate_schedule_mode, mode_to_interval_minutes};
|
||||||
|
|
||||||
// Validate scan_mode
|
// Validate scan_mode
|
||||||
let valid_modes = ["manual", "hourly", "daily", "weekly"];
|
validate_schedule_mode(&input.scan_mode)
|
||||||
if !valid_modes.contains(&input.scan_mode.as_str()) {
|
.map_err(|e| ApiError::bad_request(format!("scan_mode {e}")))?;
|
||||||
return Err(ApiError::bad_request("scan_mode must be one of: manual, hourly, daily, weekly"));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate metadata_refresh_mode
|
// Validate metadata_refresh_mode
|
||||||
let metadata_refresh_mode = input.metadata_refresh_mode.as_deref().unwrap_or("manual");
|
let metadata_refresh_mode = input.metadata_refresh_mode.as_deref().unwrap_or("manual");
|
||||||
if !valid_modes.contains(&metadata_refresh_mode) {
|
validate_schedule_mode(metadata_refresh_mode)
|
||||||
return Err(ApiError::bad_request("metadata_refresh_mode must be one of: manual, hourly, daily, weekly"));
|
.map_err(|e| ApiError::bad_request(format!("metadata_refresh_mode {e}")))?;
|
||||||
}
|
|
||||||
|
|
||||||
// Validate download_detection_mode
|
// Validate download_detection_mode
|
||||||
let download_detection_mode = input.download_detection_mode.as_deref().unwrap_or("manual");
|
let download_detection_mode = input.download_detection_mode.as_deref().unwrap_or("manual");
|
||||||
if !valid_modes.contains(&download_detection_mode) {
|
validate_schedule_mode(download_detection_mode)
|
||||||
return Err(ApiError::bad_request("download_detection_mode must be one of: manual, hourly, daily, weekly"));
|
.map_err(|e| ApiError::bad_request(format!("download_detection_mode {e}")))?;
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate next_scan_at if monitoring is enabled
|
// Calculate next_scan_at if monitoring is enabled
|
||||||
let next_scan_at = if input.monitor_enabled {
|
let next_scan_at = if input.monitor_enabled {
|
||||||
let interval_minutes = match input.scan_mode.as_str() {
|
Some(chrono::Utc::now() + chrono::Duration::minutes(mode_to_interval_minutes(&input.scan_mode)))
|
||||||
"hourly" => 60,
|
|
||||||
"daily" => 1440,
|
|
||||||
"weekly" => 10080,
|
|
||||||
_ => 1440,
|
|
||||||
};
|
|
||||||
Some(chrono::Utc::now() + chrono::Duration::minutes(interval_minutes))
|
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
|
||||||
// Calculate next_metadata_refresh_at
|
// Calculate next_metadata_refresh_at
|
||||||
let next_metadata_refresh_at = if metadata_refresh_mode != "manual" {
|
let next_metadata_refresh_at = if metadata_refresh_mode != "manual" {
|
||||||
let interval_minutes = match metadata_refresh_mode {
|
Some(chrono::Utc::now() + chrono::Duration::minutes(mode_to_interval_minutes(metadata_refresh_mode)))
|
||||||
"hourly" => 60,
|
|
||||||
"daily" => 1440,
|
|
||||||
"weekly" => 10080,
|
|
||||||
_ => 1440,
|
|
||||||
};
|
|
||||||
Some(chrono::Utc::now() + chrono::Duration::minutes(interval_minutes))
|
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
|
||||||
// Calculate next_download_detection_at
|
// Calculate next_download_detection_at
|
||||||
let next_download_detection_at = if download_detection_mode != "manual" {
|
let next_download_detection_at = if download_detection_mode != "manual" {
|
||||||
let interval_minutes = match download_detection_mode {
|
Some(chrono::Utc::now() + chrono::Duration::minutes(mode_to_interval_minutes(download_detection_mode)))
|
||||||
"hourly" => 60,
|
|
||||||
"daily" => 1440,
|
|
||||||
"weekly" => 10080,
|
|
||||||
_ => 1440,
|
|
||||||
};
|
|
||||||
Some(chrono::Utc::now() + chrono::Duration::minutes(interval_minutes))
|
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
@@ -553,20 +533,14 @@ pub async fn update_reading_status_provider(
|
|||||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||||
let provider = input.reading_status_provider.as_deref().filter(|s| !s.is_empty());
|
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");
|
let push_mode = input.reading_status_push_mode.as_deref().unwrap_or("manual");
|
||||||
if !valid_modes.contains(&push_mode) {
|
validate_schedule_mode(push_mode)
|
||||||
return Err(ApiError::bad_request("reading_status_push_mode must be one of: manual, hourly, daily, weekly"));
|
.map_err(|e| ApiError::bad_request(format!("reading_status_push_mode {e}")))?;
|
||||||
}
|
|
||||||
|
|
||||||
let next_push_at = if push_mode != "manual" {
|
let next_push_at = if push_mode != "manual" {
|
||||||
let interval_minutes: i64 = match push_mode {
|
Some(chrono::Utc::now() + chrono::Duration::minutes(mode_to_interval_minutes(push_mode)))
|
||||||
"hourly" => 60,
|
|
||||||
"daily" => 1440,
|
|
||||||
"weekly" => 10080,
|
|
||||||
_ => 1440,
|
|
||||||
};
|
|
||||||
Some(chrono::Utc::now() + chrono::Duration::minutes(interval_minutes))
|
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -38,7 +38,6 @@ pub struct SeriesCandidateDto {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize, ToSchema)]
|
#[derive(Deserialize, ToSchema)]
|
||||||
#[allow(dead_code)]
|
|
||||||
pub struct MetadataMatchRequest {
|
pub struct MetadataMatchRequest {
|
||||||
pub library_id: String,
|
pub library_id: String,
|
||||||
pub series_name: String,
|
pub series_name: String,
|
||||||
@@ -46,6 +45,7 @@ pub struct MetadataMatchRequest {
|
|||||||
pub external_id: String,
|
pub external_id: String,
|
||||||
pub external_url: Option<String>,
|
pub external_url: Option<String>,
|
||||||
pub confidence: Option<f32>,
|
pub confidence: Option<f32>,
|
||||||
|
#[allow(dead_code)]
|
||||||
pub title: String,
|
pub title: String,
|
||||||
pub metadata_json: serde_json::Value,
|
pub metadata_json: serde_json::Value,
|
||||||
pub total_volumes: Option<i32>,
|
pub total_volumes: Option<i32>,
|
||||||
|
|||||||
@@ -21,14 +21,7 @@ use uuid::Uuid;
|
|||||||
|
|
||||||
use crate::{error::ApiError, state::AppState};
|
use crate::{error::ApiError, state::AppState};
|
||||||
|
|
||||||
fn remap_libraries_path(path: &str) -> String {
|
use stripstream_core::paths::remap_libraries_path;
|
||||||
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 parse_filter(s: &str) -> image::imageops::FilterType {
|
fn parse_filter(s: &str) -> image::imageops::FilterType {
|
||||||
match s {
|
match s {
|
||||||
|
|||||||
@@ -289,9 +289,7 @@ fn extract_volumes_from_title(title: &str) -> Vec<i32> {
|
|||||||
j += 1;
|
j += 1;
|
||||||
}
|
}
|
||||||
if j > 0 && j < chars.len() {
|
if j > 0 && j < chars.len() {
|
||||||
let valid_sep = chars[j] == '.'
|
let valid_sep = chars[j] == '.' || chars[j] == ' ';
|
||||||
|| chars[j] == ' '
|
|
||||||
|| (j + 2 < chars.len() && chars[j] == ' ' && chars[j + 1] == '-');
|
|
||||||
if valid_sep {
|
if valid_sep {
|
||||||
let num_str: String = chars[..j].iter().collect();
|
let num_str: String = chars[..j].iter().collect();
|
||||||
if let Ok(num) = num_str.parse::<i32>() {
|
if let Ok(num) = num_str.parse::<i32>() {
|
||||||
|
|||||||
@@ -1024,23 +1024,7 @@ fn remap_downloads_path(path: &str) -> String {
|
|||||||
path.to_string()
|
path.to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn remap_libraries_path(path: &str) -> String {
|
use stripstream_core::paths::{remap_libraries_path, unmap_libraries_path};
|
||||||
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()
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Naming helpers ───────────────────────────────────────────────────────────
|
// ─── Naming helpers ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use sqlx::{PgPool, Row};
|
use sqlx::{PgPool, Row};
|
||||||
|
use stripstream_core::schedule::mode_to_interval_minutes;
|
||||||
use tracing::info;
|
use tracing::info;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
@@ -45,12 +46,7 @@ pub async fn check_and_schedule_auto_scans(pool: &PgPool) -> Result<()> {
|
|||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
// Update next_scan_at
|
// Update next_scan_at
|
||||||
let interval_minutes = match scan_mode.as_str() {
|
let interval_minutes = mode_to_interval_minutes(&scan_mode);
|
||||||
"hourly" => 60,
|
|
||||||
"daily" => 1440,
|
|
||||||
"weekly" => 10080,
|
|
||||||
_ => 1440, // default daily
|
|
||||||
};
|
|
||||||
|
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"UPDATE libraries SET last_scan_at = NOW(), next_scan_at = NOW() + INTERVAL '1 minute' * $2 WHERE id = $1"
|
"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)
|
.execute(pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let interval_minutes: i64 = match push_mode.as_str() {
|
let interval_minutes = mode_to_interval_minutes(&push_mode);
|
||||||
"hourly" => 60,
|
|
||||||
"daily" => 1440,
|
|
||||||
"weekly" => 10080,
|
|
||||||
_ => 1440,
|
|
||||||
};
|
|
||||||
|
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"UPDATE libraries SET last_reading_status_push_at = NOW(), next_reading_status_push_at = NOW() + INTERVAL '1 minute' * $2 WHERE id = $1"
|
"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)
|
.execute(pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let interval_minutes: i64 = match detection_mode.as_str() {
|
let interval_minutes = mode_to_interval_minutes(&detection_mode);
|
||||||
"hourly" => 60,
|
|
||||||
"daily" => 1440,
|
|
||||||
"weekly" => 10080,
|
|
||||||
_ => 1440,
|
|
||||||
};
|
|
||||||
|
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"UPDATE libraries SET last_download_detection_at = NOW(), next_download_detection_at = NOW() + INTERVAL '1 minute' * $2 WHERE id = $1"
|
"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)
|
.execute(pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let interval_minutes = match refresh_mode.as_str() {
|
let interval_minutes = mode_to_interval_minutes(&refresh_mode);
|
||||||
"hourly" => 60,
|
|
||||||
"daily" => 1440,
|
|
||||||
"weekly" => 10080,
|
|
||||||
_ => 1440,
|
|
||||||
};
|
|
||||||
|
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"UPDATE libraries SET last_metadata_refresh_at = NOW(), next_metadata_refresh_at = NOW() + INTERVAL '1 minute' * $2 WHERE id = $1"
|
"UPDATE libraries SET last_metadata_refresh_at = NOW(), next_metadata_refresh_at = NOW() + INTERVAL '1 minute' * $2 WHERE id = $1"
|
||||||
|
|||||||
@@ -5,23 +5,8 @@ use sha2::{Digest, Sha256};
|
|||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
|
|
||||||
pub fn remap_libraries_path(path: &str) -> String {
|
pub use stripstream_core::paths::remap_libraries_path;
|
||||||
if let Ok(root) = std::env::var("LIBRARIES_ROOT_PATH") {
|
pub use stripstream_core::paths::unmap_libraries_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 fn compute_fingerprint(path: &Path, size: u64, mtime: &DateTime<Utc>) -> Result<String> {
|
pub fn compute_fingerprint(path: &Path, size: u64, mtime: &DateTime<Utc>) -> Result<String> {
|
||||||
// Optimized: only use size + mtime + first bytes of filename for fast fingerprinting
|
// Optimized: only use size + mtime + first bytes of filename for fast fingerprinting
|
||||||
|
|||||||
@@ -50,38 +50,33 @@ impl Default for ThumbnailConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Parse an environment variable with a fallback default value.
|
||||||
|
pub fn env_or<T: std::str::FromStr>(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 {
|
impl IndexerConfig {
|
||||||
pub fn from_env() -> Result<Self> {
|
pub fn from_env() -> Result<Self> {
|
||||||
let thumbnail_config = ThumbnailConfig {
|
let mut thumbnail_config = ThumbnailConfig::default();
|
||||||
enabled: std::env::var("THUMBNAIL_ENABLED")
|
thumbnail_config.enabled = env_or("THUMBNAIL_ENABLED", thumbnail_config.enabled);
|
||||||
.ok()
|
thumbnail_config.width = env_or("THUMBNAIL_WIDTH", thumbnail_config.width);
|
||||||
.and_then(|v| v.parse::<bool>().ok())
|
thumbnail_config.height = env_or("THUMBNAIL_HEIGHT", thumbnail_config.height);
|
||||||
.unwrap_or(true),
|
thumbnail_config.quality = env_or("THUMBNAIL_QUALITY", thumbnail_config.quality);
|
||||||
width: std::env::var("THUMBNAIL_WIDTH")
|
thumbnail_config.format = env_string_or("THUMBNAIL_FORMAT", &thumbnail_config.format);
|
||||||
.ok()
|
thumbnail_config.directory = env_string_or("THUMBNAIL_DIRECTORY", &thumbnail_config.directory);
|
||||||
.and_then(|v| v.parse::<u32>().ok())
|
|
||||||
.unwrap_or(300),
|
|
||||||
height: std::env::var("THUMBNAIL_HEIGHT")
|
|
||||||
.ok()
|
|
||||||
.and_then(|v| v.parse::<u32>().ok())
|
|
||||||
.unwrap_or(400),
|
|
||||||
quality: std::env::var("THUMBNAIL_QUALITY")
|
|
||||||
.ok()
|
|
||||||
.and_then(|v| v.parse::<u8>().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()),
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
listen_addr: std::env::var("INDEXER_LISTEN_ADDR")
|
listen_addr: env_string_or("INDEXER_LISTEN_ADDR", "0.0.0.0:7081"),
|
||||||
.unwrap_or_else(|_| "0.0.0.0:7081".to_string()),
|
|
||||||
database_url: std::env::var("DATABASE_URL").context("DATABASE_URL is required")?,
|
database_url: std::env::var("DATABASE_URL").context("DATABASE_URL is required")?,
|
||||||
scan_interval_seconds: std::env::var("INDEXER_SCAN_INTERVAL_SECONDS")
|
scan_interval_seconds: env_or("INDEXER_SCAN_INTERVAL_SECONDS", 5),
|
||||||
.ok()
|
|
||||||
.and_then(|v| v.parse::<u64>().ok())
|
|
||||||
.unwrap_or(5),
|
|
||||||
thumbnail_config,
|
thumbnail_config,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1 +1,3 @@
|
|||||||
pub mod config;
|
pub mod config;
|
||||||
|
pub mod paths;
|
||||||
|
pub mod schedule;
|
||||||
|
|||||||
93
crates/core/src/paths.rs
Normal file
93
crates/core/src/paths.rs
Normal file
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
57
crates/core/src/schedule.rs
Normal file
57
crates/core/src/schedule.rs
Normal file
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -88,7 +88,7 @@ fn extract_series(path: &Path, library_root: &Path) -> Option<String> {
|
|||||||
} else if let Ok(relative) = parent.strip_prefix(library_root) {
|
} else if let Ok(relative) = parent.strip_prefix(library_root) {
|
||||||
relative
|
relative
|
||||||
} else {
|
} else {
|
||||||
eprintln!(
|
tracing::warn!(
|
||||||
"[PARSER] Cannot determine series: parent '{}' doesn't start with root '{}'",
|
"[PARSER] Cannot determine series: parent '{}' doesn't start with root '{}'",
|
||||||
parent.display(),
|
parent.display(),
|
||||||
library_root.display()
|
library_root.display()
|
||||||
|
|||||||
Reference in New Issue
Block a user