From 9616dfc7f6a173307185ba9e88329a2cb85a162b Mon Sep 17 00:00:00 2001 From: Sylvain Cau Date: Sat, 23 May 2026 13:42:23 -0700 Subject: [PATCH 01/14] refactor(layering): hoist cross-layer types into models/ to break dep cycles Eliminate the wrong-direction imports identified in the dep audit so the src/ subdirectories form a clean DAG. No behavior change; the codebase still compiles as a single crate. Convert src/models.rs into a src/models/ directory module and move types that both upper and lower layers need to speak: - models/permissions.rs <- api/permissions.rs (UserRole, Permission) - models/sort.rs <- dto::{series,book} (SortDirection, sort fields) - models/filter.rs <- dto/filter.rs (operator + condition enums) - models/task.rs <- tasks/types.rs (TaskType, TaskResult, ...) - models/release.rs <- services/release/* (NumericSpan, OwnedReleaseKeys) - models/plugin.rs <- services/plugin/protocol.rs (PluginManifest, PluginScope, capabilities, OAuth, credentials) - models/preprocessing.rs <- services/metadata/preprocessing/types.rs Move two service-shaped utilities to their natural homes: - ContentFilter: api/extractors -> services/content_filter - CredentialEncryption: services/plugin/encryption -> utils/credential_encryption Break services -> scheduler by introducing services::scheduler_handle:: SchedulerReconciler (boxed-future trait, object-safe). The plugin manager and releases handler hold Arc; scheduler:: LockedSchedulerReconciler adapts the concrete Scheduler behind a Mutex. Old paths (codex::api::permissions::*, services::CredentialEncryption, tasks::types::*, services::plugin::protocol::PluginManifest, etc.) all remain accessible via pub-use shims so the integration tests and any external code that imports them keep compiling. Validated with cargo fmt, cargo clippy --workspace --all-targets -D warnings, and make test-fast. --- src/api/extractors/mod.rs | 4 +- src/api/permissions.rs | 535 +----- src/api/routes/v1/dto/book.rs | 119 +- src/api/routes/v1/dto/filter.rs | 280 +--- src/api/routes/v1/dto/series.rs | 173 +- src/commands/serve.rs | 7 +- src/db/entities/plugins.rs | 8 +- src/db/entities/users.rs | 2 +- src/db/repositories/book.rs | 10 +- src/db/repositories/library.rs | 10 +- src/db/repositories/plugin_failures.rs | 2 +- src/db/repositories/plugins.rs | 12 +- src/db/repositories/release_ledger.rs | 2 +- src/db/repositories/series.rs | 6 +- src/db/repositories/task.rs | 5 +- src/db/repositories/user_plugins.rs | 2 +- src/models/filter.rs | 275 +++ src/models/mod.rs | 18 + src/models/permissions.rs | 527 ++++++ src/models/plugin.rs | 426 +++++ src/models/preprocessing.rs | 562 +++++++ src/models/release.rs | 107 ++ src/models/sort.rs | 293 ++++ src/{models.rs => models/strategies.rs} | 7 +- src/models/task.rs | 1493 +++++++++++++++++ src/scheduler/mod.rs | 23 + src/services/book_export_collector.rs | 2 +- src/services/cleanup_subscriber.rs | 4 +- .../extractors => services}/content_filter.rs | 0 src/services/filter.rs | 8 +- src/services/metadata/cover.rs | 2 +- src/services/metadata/preprocessing/types.rs | 564 +------ src/services/mod.rs | 6 +- src/services/plugin/handle.rs | 8 +- src/services/plugin/manager.rs | 4 +- src/services/plugin/mod.rs | 1 - src/services/plugin/protocol.rs | 425 +---- src/services/plugin/releases_handler.rs | 22 +- src/services/release/auto_ignore.rs | 22 +- src/services/release/candidate.rs | 80 +- src/services/scheduler_handle.rs | 29 + src/services/series_export_collector.rs | 2 +- src/tasks/error.rs | 10 +- src/tasks/types.rs | 1491 +--------------- .../credential_encryption.rs} | 0 src/utils/jwt.rs | 2 +- src/utils/mod.rs | 1 + 47 files changed, 3885 insertions(+), 3706 deletions(-) create mode 100644 src/models/filter.rs create mode 100644 src/models/mod.rs create mode 100644 src/models/permissions.rs create mode 100644 src/models/plugin.rs create mode 100644 src/models/preprocessing.rs create mode 100644 src/models/release.rs create mode 100644 src/models/sort.rs rename src/{models.rs => models/strategies.rs} (98%) create mode 100644 src/models/task.rs rename src/{api/extractors => services}/content_filter.rs (100%) create mode 100644 src/services/scheduler_handle.rs rename src/{services/plugin/encryption.rs => utils/credential_encryption.rs} (100%) diff --git a/src/api/extractors/mod.rs b/src/api/extractors/mod.rs index 03fa60bd..b89cb388 100644 --- a/src/api/extractors/mod.rs +++ b/src/api/extractors/mod.rs @@ -1,9 +1,9 @@ pub mod auth; pub mod client_info; -pub mod content_filter; // AuthMethod is part of the public API for auth context inspection #[allow(unused_imports)] pub use auth::{AppState, AuthContext, AuthMethod, AuthState, FlexibleAuthContext}; pub use client_info::ClientInfo; -pub use content_filter::ContentFilter; +// Historical alias. The canonical location is `crate::services::content_filter`. +pub use crate::services::content_filter::ContentFilter; diff --git a/src/api/permissions.rs b/src/api/permissions.rs index e31848f1..03d72562 100644 --- a/src/api/permissions.rs +++ b/src/api/permissions.rs @@ -1,527 +1,8 @@ -use serde::{Deserialize, Serialize}; -use std::collections::HashSet; -use std::fmt; -use std::str::FromStr; -use utoipa::ToSchema; - -/// User roles for role-based access control (RBAC) -/// -/// Roles define a base set of permissions that users inherit. -/// Custom permissions can be added on top of role permissions (union behavior). -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, ToSchema, Default)] -#[serde(rename_all = "lowercase")] -pub enum UserRole { - /// Basic read access - can browse and read content - #[default] - Reader, - /// Content management - can modify series, books, run scans - Maintainer, - /// Full system access - can manage users, system settings - Admin, -} - -impl UserRole { - /// Get the permission set associated with this role - pub fn permissions(&self) -> &'static HashSet { - match self { - UserRole::Reader => &READER_PERMISSIONS, - UserRole::Maintainer => &MAINTAINER_PERMISSIONS, - UserRole::Admin => &ADMIN_PERMISSIONS, - } - } - - /// Check if this role can assign another role to a user - /// - /// Admin can assign any role, Maintainer can only assign Reader, - /// Reader cannot assign roles. - #[allow(dead_code)] // Reserved for the user role assignment API - pub fn can_assign(&self, target: UserRole) -> bool { - match self { - UserRole::Admin => true, - UserRole::Maintainer => target == UserRole::Reader, - UserRole::Reader => false, - } - } - - /// Returns all possible role values - #[allow(dead_code)] // Reserved for the user role assignment API - pub fn all() -> &'static [UserRole] { - &[UserRole::Reader, UserRole::Maintainer, UserRole::Admin] - } -} - -impl fmt::Display for UserRole { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - UserRole::Reader => write!(f, "reader"), - UserRole::Maintainer => write!(f, "maintainer"), - UserRole::Admin => write!(f, "admin"), - } - } -} - -impl FromStr for UserRole { - type Err = String; - - fn from_str(s: &str) -> Result { - match s.to_lowercase().as_str() { - "reader" => Ok(UserRole::Reader), - "maintainer" => Ok(UserRole::Maintainer), - "admin" => Ok(UserRole::Admin), - _ => Err(format!("Unknown role: {}", s)), - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, ToSchema)] -#[serde(rename_all = "kebab-case")] -pub enum Permission { - // Libraries - LibrariesRead, - LibrariesWrite, - LibrariesDelete, - - // Series - SeriesRead, - SeriesWrite, - SeriesDelete, - - // Books - BooksRead, - BooksWrite, - BooksDelete, - - // Pages (image serving) - PagesRead, - - // Progress (reading progress tracking) - ProgressRead, - ProgressWrite, - - // Users (admin only) - UsersRead, - UsersWrite, - UsersDelete, - - // API Keys (admin only) - ApiKeysRead, - ApiKeysWrite, - ApiKeysDelete, - - // Tasks - TasksRead, - TasksWrite, - - // Plugins (admin configuration) - PluginsManage, - - // System - SystemHealth, - SystemAdmin, -} - -#[allow(dead_code)] // Public API for permission string representation -impl Permission { - /// Convert permission to string format: "resource:action" - pub fn as_str(&self) -> &'static str { - match self { - Permission::LibrariesRead => "libraries:read", - Permission::LibrariesWrite => "libraries:write", - Permission::LibrariesDelete => "libraries:delete", - Permission::SeriesRead => "series:read", - Permission::SeriesWrite => "series:write", - Permission::SeriesDelete => "series:delete", - Permission::BooksRead => "books:read", - Permission::BooksWrite => "books:write", - Permission::BooksDelete => "books:delete", - Permission::PagesRead => "pages:read", - Permission::ProgressRead => "progress:read", - Permission::ProgressWrite => "progress:write", - Permission::UsersRead => "users:read", - Permission::UsersWrite => "users:write", - Permission::UsersDelete => "users:delete", - Permission::ApiKeysRead => "api-keys:read", - Permission::ApiKeysWrite => "api-keys:write", - Permission::ApiKeysDelete => "api-keys:delete", - Permission::TasksRead => "tasks:read", - Permission::TasksWrite => "tasks:write", - Permission::PluginsManage => "plugins:manage", - Permission::SystemHealth => "system:health", - Permission::SystemAdmin => "system:admin", - } - } -} - -impl FromStr for Permission { - type Err = String; - - fn from_str(s: &str) -> Result { - match s { - "libraries:read" => Ok(Permission::LibrariesRead), - "libraries:write" => Ok(Permission::LibrariesWrite), - "libraries:delete" => Ok(Permission::LibrariesDelete), - "series:read" => Ok(Permission::SeriesRead), - "series:write" => Ok(Permission::SeriesWrite), - "series:delete" => Ok(Permission::SeriesDelete), - "books:read" => Ok(Permission::BooksRead), - "books:write" => Ok(Permission::BooksWrite), - "books:delete" => Ok(Permission::BooksDelete), - "pages:read" => Ok(Permission::PagesRead), - "progress:read" => Ok(Permission::ProgressRead), - "progress:write" => Ok(Permission::ProgressWrite), - "users:read" => Ok(Permission::UsersRead), - "users:write" => Ok(Permission::UsersWrite), - "users:delete" => Ok(Permission::UsersDelete), - "api-keys:read" => Ok(Permission::ApiKeysRead), - "api-keys:write" => Ok(Permission::ApiKeysWrite), - "api-keys:delete" => Ok(Permission::ApiKeysDelete), - "tasks:read" => Ok(Permission::TasksRead), - "tasks:write" => Ok(Permission::TasksWrite), - "plugins:manage" => Ok(Permission::PluginsManage), - "system:health" => Ok(Permission::SystemHealth), - "system:admin" => Ok(Permission::SystemAdmin), - _ => Err(format!("Unknown permission: {}", s)), - } - } -} - -/// Parse permissions from JSON string -#[allow(dead_code)] // Public API for permission parsing -pub fn parse_permissions(json: &str) -> Result, serde_json::Error> { - let perms: Vec = serde_json::from_str(json)?; - Ok(perms.into_iter().collect()) -} - -/// Serialize permissions to JSON string -pub fn serialize_permissions(permissions: &HashSet) -> String { - let perms: Vec = permissions.iter().cloned().collect(); - serde_json::to_string(&perms).unwrap_or_else(|_| "[]".to_string()) -} - -// Preset permission sets -lazy_static::lazy_static! { - /// Read-only permissions (basic read access - legacy, kept for backwards compatibility) - pub static ref READONLY_PERMISSIONS: HashSet = { - let mut set = HashSet::new(); - set.insert(Permission::LibrariesRead); - set.insert(Permission::SeriesRead); - set.insert(Permission::BooksRead); - set.insert(Permission::PagesRead); - set.insert(Permission::ProgressRead); - set.insert(Permission::ProgressWrite); - set.insert(Permission::SystemHealth); - set - }; - - /// Reader role permissions - /// - /// Reader can: - /// - Browse libraries, series, and books - /// - Read pages/content - /// - Manage their own API keys - /// - View system health - pub static ref READER_PERMISSIONS: HashSet = { - let mut set = HashSet::new(); - // Content access - set.insert(Permission::LibrariesRead); - set.insert(Permission::SeriesRead); - set.insert(Permission::BooksRead); - set.insert(Permission::PagesRead); - // Progress tracking - set.insert(Permission::ProgressRead); - set.insert(Permission::ProgressWrite); - // Own API keys - set.insert(Permission::ApiKeysRead); - set.insert(Permission::ApiKeysWrite); - set.insert(Permission::ApiKeysDelete); - // System - set.insert(Permission::SystemHealth); - set - }; - - /// Maintainer role permissions - /// - /// Maintainer can do everything Reader can, plus: - /// - Create/modify libraries (but not delete) - /// - Create/modify/delete series - /// - Create/modify/delete books - /// - View and manage tasks - pub static ref MAINTAINER_PERMISSIONS: HashSet = { - let mut set = READER_PERMISSIONS.clone(); - // Libraries (create/modify, but not delete) - set.insert(Permission::LibrariesWrite); - // Series (full control) - set.insert(Permission::SeriesWrite); - set.insert(Permission::SeriesDelete); - // Books (full control) - set.insert(Permission::BooksWrite); - set.insert(Permission::BooksDelete); - // Tasks (view and manage) - set.insert(Permission::TasksRead); - set.insert(Permission::TasksWrite); - set - }; - - /// Admin role permissions (all permissions) - /// - /// Admin can do everything, including: - /// - Delete libraries - /// - Manage users - /// - Manage plugins - /// - System administration - pub static ref ADMIN_PERMISSIONS: HashSet = { - let mut set = MAINTAINER_PERMISSIONS.clone(); - // Libraries (full control including delete) - set.insert(Permission::LibrariesDelete); - // Users (full control) - set.insert(Permission::UsersRead); - set.insert(Permission::UsersWrite); - set.insert(Permission::UsersDelete); - // Plugins (configuration) - set.insert(Permission::PluginsManage); - // System admin - set.insert(Permission::SystemAdmin); - set - }; -} - -#[cfg(test)] -mod tests { - use super::*; - - // ============== Permission tests ============== - - #[test] - fn test_permission_as_str() { - assert_eq!(Permission::LibrariesRead.as_str(), "libraries:read"); - assert_eq!(Permission::BooksWrite.as_str(), "books:write"); - assert_eq!(Permission::PluginsManage.as_str(), "plugins:manage"); - assert_eq!(Permission::SystemAdmin.as_str(), "system:admin"); - } - - #[test] - fn test_permission_from_str() { - assert_eq!( - Permission::from_str("libraries:read").unwrap(), - Permission::LibrariesRead - ); - assert_eq!( - Permission::from_str("books:write").unwrap(), - Permission::BooksWrite - ); - assert_eq!( - Permission::from_str("plugins:manage").unwrap(), - Permission::PluginsManage - ); - assert!(Permission::from_str("invalid:permission").is_err()); - } - - #[test] - fn test_parse_permissions() { - let json = r#"["libraries-read", "books-read", "pages-read"]"#; - let perms = parse_permissions(json).unwrap(); - - assert_eq!(perms.len(), 3); - assert!(perms.contains(&Permission::LibrariesRead)); - assert!(perms.contains(&Permission::BooksRead)); - assert!(perms.contains(&Permission::PagesRead)); - } - - #[test] - fn test_serialize_permissions() { - let mut perms = HashSet::new(); - perms.insert(Permission::LibrariesRead); - perms.insert(Permission::BooksRead); - - let json = serialize_permissions(&perms); - let parsed = parse_permissions(&json).unwrap(); - - assert_eq!(parsed.len(), 2); - assert!(parsed.contains(&Permission::LibrariesRead)); - assert!(parsed.contains(&Permission::BooksRead)); - } - - #[test] - fn test_readonly_permissions() { - assert!(READONLY_PERMISSIONS.contains(&Permission::LibrariesRead)); - assert!(READONLY_PERMISSIONS.contains(&Permission::BooksRead)); - assert!(!READONLY_PERMISSIONS.contains(&Permission::LibrariesWrite)); - assert_eq!(READONLY_PERMISSIONS.len(), 7); - } - - // ============== Role permission preset tests ============== - - #[test] - fn test_reader_permissions() { - // Reader has basic content access - assert!(READER_PERMISSIONS.contains(&Permission::LibrariesRead)); - assert!(READER_PERMISSIONS.contains(&Permission::SeriesRead)); - assert!(READER_PERMISSIONS.contains(&Permission::BooksRead)); - assert!(READER_PERMISSIONS.contains(&Permission::PagesRead)); - // Reader has API key management for themselves - assert!(READER_PERMISSIONS.contains(&Permission::ApiKeysRead)); - assert!(READER_PERMISSIONS.contains(&Permission::ApiKeysWrite)); - assert!(READER_PERMISSIONS.contains(&Permission::ApiKeysDelete)); - // Reader has system health - assert!(READER_PERMISSIONS.contains(&Permission::SystemHealth)); - // Reader cannot view or manage tasks - assert!(!READER_PERMISSIONS.contains(&Permission::TasksRead)); - assert!(!READER_PERMISSIONS.contains(&Permission::TasksWrite)); - // Reader can track reading progress - assert!(READER_PERMISSIONS.contains(&Permission::ProgressRead)); - assert!(READER_PERMISSIONS.contains(&Permission::ProgressWrite)); - // Reader cannot modify content - assert!(!READER_PERMISSIONS.contains(&Permission::BooksWrite)); - assert!(!READER_PERMISSIONS.contains(&Permission::SeriesWrite)); - assert!(!READER_PERMISSIONS.contains(&Permission::LibrariesWrite)); - // Reader cannot manage users or system - assert!(!READER_PERMISSIONS.contains(&Permission::UsersRead)); - assert!(!READER_PERMISSIONS.contains(&Permission::SystemAdmin)); - - assert_eq!(READER_PERMISSIONS.len(), 10); - } - - #[test] - fn test_maintainer_permissions() { - // Maintainer is a superset of Reader - for perm in READER_PERMISSIONS.iter() { - assert!( - MAINTAINER_PERMISSIONS.contains(perm), - "Maintainer missing Reader permission: {:?}", - perm - ); - } - // Maintainer can modify libraries (but not delete) - assert!(MAINTAINER_PERMISSIONS.contains(&Permission::LibrariesWrite)); - assert!(!MAINTAINER_PERMISSIONS.contains(&Permission::LibrariesDelete)); - // Maintainer can fully manage series and books - assert!(MAINTAINER_PERMISSIONS.contains(&Permission::SeriesWrite)); - assert!(MAINTAINER_PERMISSIONS.contains(&Permission::SeriesDelete)); - assert!(MAINTAINER_PERMISSIONS.contains(&Permission::BooksWrite)); - assert!(MAINTAINER_PERMISSIONS.contains(&Permission::BooksDelete)); - // Maintainer can manage tasks - assert!(MAINTAINER_PERMISSIONS.contains(&Permission::TasksWrite)); - // Maintainer cannot manage users or system admin - assert!(!MAINTAINER_PERMISSIONS.contains(&Permission::UsersRead)); - assert!(!MAINTAINER_PERMISSIONS.contains(&Permission::SystemAdmin)); - - assert_eq!(MAINTAINER_PERMISSIONS.len(), 17); - } - - #[test] - fn test_admin_permissions() { - // Admin is a superset of Maintainer - for perm in MAINTAINER_PERMISSIONS.iter() { - assert!( - ADMIN_PERMISSIONS.contains(perm), - "Admin missing Maintainer permission: {:?}", - perm - ); - } - // Admin has library delete - assert!(ADMIN_PERMISSIONS.contains(&Permission::LibrariesDelete)); - // Admin has full user management - assert!(ADMIN_PERMISSIONS.contains(&Permission::UsersRead)); - assert!(ADMIN_PERMISSIONS.contains(&Permission::UsersWrite)); - assert!(ADMIN_PERMISSIONS.contains(&Permission::UsersDelete)); - // Admin has plugin management - assert!(ADMIN_PERMISSIONS.contains(&Permission::PluginsManage)); - // Admin has system admin - assert!(ADMIN_PERMISSIONS.contains(&Permission::SystemAdmin)); - - assert_eq!(ADMIN_PERMISSIONS.len(), 23); // All permissions - } - - // ============== UserRole tests ============== - - #[test] - fn test_user_role_from_str() { - assert_eq!(UserRole::from_str("reader").unwrap(), UserRole::Reader); - assert_eq!(UserRole::from_str("Reader").unwrap(), UserRole::Reader); - assert_eq!(UserRole::from_str("READER").unwrap(), UserRole::Reader); - assert_eq!( - UserRole::from_str("maintainer").unwrap(), - UserRole::Maintainer - ); - assert_eq!(UserRole::from_str("admin").unwrap(), UserRole::Admin); - assert!(UserRole::from_str("invalid").is_err()); - } - - #[test] - fn test_user_role_display() { - assert_eq!(UserRole::Reader.to_string(), "reader"); - assert_eq!(UserRole::Maintainer.to_string(), "maintainer"); - assert_eq!(UserRole::Admin.to_string(), "admin"); - } - - #[test] - fn test_user_role_default() { - assert_eq!(UserRole::default(), UserRole::Reader); - } - - #[test] - fn test_user_role_permissions() { - assert_eq!(UserRole::Reader.permissions(), &*READER_PERMISSIONS); - assert_eq!(UserRole::Maintainer.permissions(), &*MAINTAINER_PERMISSIONS); - assert_eq!(UserRole::Admin.permissions(), &*ADMIN_PERMISSIONS); - } - - #[test] - fn test_user_role_can_assign() { - // Admin can assign any role - assert!(UserRole::Admin.can_assign(UserRole::Reader)); - assert!(UserRole::Admin.can_assign(UserRole::Maintainer)); - assert!(UserRole::Admin.can_assign(UserRole::Admin)); - - // Maintainer can only assign Reader - assert!(UserRole::Maintainer.can_assign(UserRole::Reader)); - assert!(!UserRole::Maintainer.can_assign(UserRole::Maintainer)); - assert!(!UserRole::Maintainer.can_assign(UserRole::Admin)); - - // Reader cannot assign any role - assert!(!UserRole::Reader.can_assign(UserRole::Reader)); - assert!(!UserRole::Reader.can_assign(UserRole::Maintainer)); - assert!(!UserRole::Reader.can_assign(UserRole::Admin)); - } - - #[test] - fn test_user_role_all() { - let all_roles = UserRole::all(); - assert_eq!(all_roles.len(), 3); - assert!(all_roles.contains(&UserRole::Reader)); - assert!(all_roles.contains(&UserRole::Maintainer)); - assert!(all_roles.contains(&UserRole::Admin)); - } - - #[test] - fn test_user_role_serialization() { - // Test serialization - let role = UserRole::Admin; - let json = serde_json::to_string(&role).unwrap(); - assert_eq!(json, "\"admin\""); - - // Test deserialization - let deserialized: UserRole = serde_json::from_str("\"maintainer\"").unwrap(); - assert_eq!(deserialized, UserRole::Maintainer); - } - - #[test] - fn test_role_hierarchy_is_proper_superset() { - // Each role should be a proper superset of the previous one - // Reader < Maintainer < Admin - - let reader = &*READER_PERMISSIONS; - let maintainer = &*MAINTAINER_PERMISSIONS; - let admin = &*ADMIN_PERMISSIONS; - - // Maintainer is a proper superset of Reader - assert!(reader.is_subset(maintainer)); - assert!(!maintainer.is_subset(reader)); - assert!(maintainer.len() > reader.len()); - - // Admin is a proper superset of Maintainer - assert!(maintainer.is_subset(admin)); - assert!(!admin.is_subset(maintainer)); - assert!(admin.len() > maintainer.len()); - } -} +//! Re-export of the cross-layer permission types. +//! +//! The canonical definitions live in [`crate::models::permissions`] so that +//! the db and utils layers can reference `UserRole` without depending on the +//! api layer. This module preserves the historic `codex::api::permissions::*` +//! path used by integration tests and downstream code. + +pub use crate::models::permissions::*; diff --git a/src/api/routes/v1/dto/book.rs b/src/api/routes/v1/dto/book.rs index 4d71b7a5..765838c1 100644 --- a/src/api/routes/v1/dto/book.rs +++ b/src/api/routes/v1/dto/book.rs @@ -1,5 +1,4 @@ use std::fmt; -use std::str::FromStr; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; @@ -7,7 +6,6 @@ use utoipa::ToSchema; use super::common::PaginatedResponse; use super::read_progress::ReadProgressResponse; -use super::series::SortDirection; // Re-export BookType from entity for API use pub use crate::db::entities::book_metadata::BookType; @@ -410,120 +408,9 @@ pub struct BookCoverListResponse { pub covers: Vec, } -/// Sort field options for book list queries -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, ToSchema)] -#[serde(rename_all = "snake_case")] -pub enum BookSortField { - /// Compound sort: series name alphabetically, then books by number within series - /// This is the "reading order" sort - Series, - /// Sort by book title - #[default] - Title, - /// Sort by date added to library - DateAdded, - /// Sort by release date - ReleaseDate, - /// Sort by chapter/book number - ChapterNumber, - /// Sort by file size - FileSize, - /// Sort by filename - Filename, - /// Sort by page count - PageCount, - /// Sort by last read date (requires user_id for filtering) - LastRead, - /// Sort by fuzzy-search relevance score. Only meaningful when a - /// `fullTextSearch` query is present and `search.fuzzy.enabled` is on; - /// otherwise handlers fall back to the natural default (`Title`). - Relevance, -} - -impl fmt::Display for BookSortField { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - BookSortField::Series => write!(f, "series"), - BookSortField::Title => write!(f, "title"), - BookSortField::DateAdded => write!(f, "created_at"), - BookSortField::ReleaseDate => write!(f, "release_date"), - BookSortField::ChapterNumber => write!(f, "chapter_number"), - BookSortField::FileSize => write!(f, "file_size"), - BookSortField::Filename => write!(f, "filename"), - BookSortField::PageCount => write!(f, "page_count"), - BookSortField::LastRead => write!(f, "last_read"), - BookSortField::Relevance => write!(f, "relevance"), - } - } -} - -impl FromStr for BookSortField { - type Err = String; - - fn from_str(s: &str) -> Result { - match s.to_lowercase().as_str() { - "series" => Ok(BookSortField::Series), - "title" => Ok(BookSortField::Title), - "created_at" | "date_added" => Ok(BookSortField::DateAdded), - "release_date" => Ok(BookSortField::ReleaseDate), - "chapter_number" | "number" => Ok(BookSortField::ChapterNumber), - "file_size" => Ok(BookSortField::FileSize), - "filename" => Ok(BookSortField::Filename), - "page_count" => Ok(BookSortField::PageCount), - "last_read" | "read_date" => Ok(BookSortField::LastRead), - "relevance" | "score" => Ok(BookSortField::Relevance), - _ => Err(format!("Invalid sort field: {}", s)), - } - } -} - -/// Parsed sort parameter for book queries -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct BookSortParam { - pub field: BookSortField, - pub direction: SortDirection, -} - -impl Default for BookSortParam { - fn default() -> Self { - Self { - field: BookSortField::Title, - direction: SortDirection::Asc, - } - } -} - -impl BookSortParam { - /// Parse from "field,direction" format (e.g., "title,asc"). - /// - /// "relevance" (with or without a direction) is accepted as a shorthand - /// that pairs with a `fullTextSearch` query. - pub fn parse(s: &str) -> Self { - let trimmed = s.trim(); - if trimmed.eq_ignore_ascii_case("relevance") || trimmed.eq_ignore_ascii_case("score") { - return Self { - field: BookSortField::Relevance, - direction: SortDirection::Desc, - }; - } - - let parts: Vec<&str> = trimmed.split(',').collect(); - if parts.len() != 2 { - return Self::default(); - } - - let field = BookSortField::from_str(parts[0]).unwrap_or_default(); - let direction = SortDirection::from_str(parts[1]).unwrap_or_default(); - - Self { field, direction } - } -} - -impl fmt::Display for BookSortParam { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{},{}", self.field, self.direction) - } -} +// Sort parameters live in `crate::models::sort` so db repositories can take +// typed sort params without depending on the api layer. +pub use crate::models::sort::{BookSortField, BookSortParam}; /// Book data transfer object #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] diff --git a/src/api/routes/v1/dto/filter.rs b/src/api/routes/v1/dto/filter.rs index 75ba1555..600de134 100644 --- a/src/api/routes/v1/dto/filter.rs +++ b/src/api/routes/v1/dto/filter.rs @@ -1,274 +1,17 @@ -use chrono::{DateTime, Utc}; +//! Filter DTOs. +//! +//! The operator and condition enums live in [`crate::models::filter`] so +//! services and repositories can speak the same vocabulary without depending +//! on the api layer. The request envelopes that wrap them remain here as API +//! contract types. + use serde::{Deserialize, Serialize}; use utoipa::ToSchema; -use uuid::Uuid; - -/// Operators for string and equality comparisons -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] -#[serde(tag = "operator", rename_all = "camelCase")] -pub enum FieldOperator { - /// Exact match - Is { value: String }, - /// Not equal - IsNot { value: String }, - /// Field is null/empty - IsNull, - /// Field is not null/empty - IsNotNull, - /// String contains (case-insensitive) - Contains { value: String }, - /// String does not contain (case-insensitive) - DoesNotContain { value: String }, - /// String starts with (case-insensitive) - BeginsWith { value: String }, - /// String ends with (case-insensitive) - EndsWith { value: String }, -} - -/// Operators for UUID comparisons (library_id, series_id, etc.) -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] -#[serde(tag = "operator", rename_all = "camelCase")] -pub enum UuidOperator { - /// Exact match - Is { value: Uuid }, - /// Not equal - IsNot { value: Uuid }, -} - -/// Operators for boolean comparisons -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] -#[serde(tag = "operator", rename_all = "camelCase")] -pub enum BoolOperator { - /// Is true - IsTrue, - /// Is false - IsFalse, -} - -/// Operators for numeric comparisons (year, page count, etc.). -/// -/// Values are deserialized as `i64` so the same operator can target either -/// `INTEGER` or `BIGINT` columns. Implementations downcast as needed. -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] -#[serde(tag = "operator", rename_all = "camelCase")] -pub enum NumberOperator { - /// Equal to value - Eq { value: i64 }, - /// Not equal to value - Ne { value: i64 }, - /// Greater than value (strict) - Gt { value: i64 }, - /// Greater than or equal to value - Gte { value: i64 }, - /// Less than value (strict) - Lt { value: i64 }, - /// Less than or equal to value - Lte { value: i64 }, - /// Inclusive range, `min <= field <= max`. Either bound may be omitted to - /// model open-ended ranges (e.g. "year >= 2000"). - Between { - #[serde(default, skip_serializing_if = "Option::is_none")] - min: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - max: Option, - }, - /// Field is null - IsNull, - /// Field is not null - IsNotNull, -} - -/// Operators for date/timestamp comparisons. -/// -/// Values are RFC 3339 / ISO 8601 timestamps. For range comparisons either -/// bound may be omitted to express an open-ended range. -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] -#[serde(tag = "operator", rename_all = "camelCase")] -pub enum DateOperator { - /// Strictly after the given timestamp - After { value: DateTime }, - /// Strictly before the given timestamp - Before { value: DateTime }, - /// On or after the given timestamp - OnOrAfter { value: DateTime }, - /// On or before the given timestamp - OnOrBefore { value: DateTime }, - /// Inclusive between range. Either bound may be omitted. - Between { - #[serde(default, skip_serializing_if = "Option::is_none")] - start: Option>, - #[serde(default, skip_serializing_if = "Option::is_none")] - end: Option>, - }, - /// Field is null - IsNull, - /// Field is not null - IsNotNull, -} -/// Series-level search conditions -/// -/// Conditions can be composed using `allOf` (AND) and `anyOf` (OR). -/// Uses untagged enum for cleaner JSON without explicit type field. -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] -#[serde(untagged)] -pub enum SeriesCondition { - /// All conditions must match (AND) - AllOf { - #[serde(rename = "allOf")] - #[schema(no_recursion)] - all_of: Vec, - }, - /// Any condition must match (OR) - AnyOf { - #[serde(rename = "anyOf")] - #[schema(no_recursion)] - any_of: Vec, - }, - /// Filter by library ID - LibraryId { - #[serde(rename = "libraryId")] - library_id: UuidOperator, - }, - /// Filter by genre name - Genre { genre: FieldOperator }, - /// Filter by tag name - Tag { tag: FieldOperator }, - /// Filter by series status (ongoing, ended, hiatus, etc.) - Status { status: FieldOperator }, - /// Filter by publisher - Publisher { publisher: FieldOperator }, - /// Filter by language - Language { language: FieldOperator }, - /// Filter by series title (`series_metadata.title`) - Title { title: FieldOperator }, - /// Filter by series title_sort field (used for alphabetical filtering) - TitleSort { - #[serde(rename = "titleSort")] - title_sort: FieldOperator, - }, - /// Filter by read status (unread, in_progress, read) - ReadStatus { - #[serde(rename = "readStatus")] - read_status: FieldOperator, - }, - /// Filter by sharing tag name - SharingTag { - #[serde(rename = "sharingTag")] - sharing_tag: FieldOperator, - }, - /// Filter by series completion status (complete/incomplete based on book_count vs total_volume_count) - Completion { completion: BoolOperator }, - /// Filter by whether the series has an external source ID linked - HasExternalSourceId { - #[serde(rename = "hasExternalSourceId")] - has_external_source_id: BoolOperator, - }, - /// Filter by whether the series has a rating from the current user - HasUserRating { - #[serde(rename = "hasUserRating")] - has_user_rating: BoolOperator, - }, - /// Filter by whether release tracking is enabled for the series. - /// - /// `IsTrue` returns only series whose `series_tracking.tracked` flag is - /// `true`. `IsFalse` returns everything else, including series with no - /// `series_tracking` row at all (the common case for a fresh library). - IsTracked { - #[serde(rename = "isTracked")] - is_tracked: BoolOperator, - }, - /// Filter by release year (from `series_metadata.year`). - Year { year: NumberOperator }, - /// Filter by author (substring match on `series_metadata.authors_json`). - /// - /// The match is performed against the raw JSON text. It is tolerant of - /// both string-list and object-list shapes but may incidentally match - /// other fields (e.g. `role`); callers wanting strict matching should - /// pre-quote the value. - Author { author: FieldOperator }, - /// Filter by the series' folder path (`series.path`). Useful for matching - /// series under a given directory. - Path { path: FieldOperator }, - /// Filter by date the series was added to the library - /// (`series.created_at`). - DateAdded { - #[serde(rename = "dateAdded")] - date_added: DateOperator, - }, -} - -/// Book-level search conditions -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] -#[serde(untagged)] -pub enum BookCondition { - /// All conditions must match (AND) - AllOf { - #[serde(rename = "allOf")] - #[schema(no_recursion)] - all_of: Vec, - }, - /// Any condition must match (OR) - AnyOf { - #[serde(rename = "anyOf")] - #[schema(no_recursion)] - any_of: Vec, - }, - /// Filter by library ID - LibraryId { - #[serde(rename = "libraryId")] - library_id: UuidOperator, - }, - /// Filter by series ID - SeriesId { - #[serde(rename = "seriesId")] - series_id: UuidOperator, - }, - /// Filter by genre name (from parent series) - Genre { genre: FieldOperator }, - /// Filter by tag name (from parent series) - Tag { tag: FieldOperator }, - /// Filter by book title (`book_metadata.title`) - Title { title: FieldOperator }, - /// Filter by book title_sort field (`book_metadata.title_sort`, - /// used for alphabetical filtering) - TitleSort { - #[serde(rename = "titleSort")] - title_sort: FieldOperator, - }, - /// Filter by read status (unread, in_progress, read) - ReadStatus { - #[serde(rename = "readStatus")] - read_status: FieldOperator, - }, - /// Filter by books with analysis errors - HasError { - #[serde(rename = "hasError")] - has_error: BoolOperator, - }, - /// Filter by book type (comic, manga, novel, etc.) - BookType { - #[serde(rename = "bookType")] - book_type: FieldOperator, - }, - /// Filter by the book's file path (`books.path`). Useful for matching - /// books under a given directory or with a specific filename fragment. - Path { path: FieldOperator }, - /// Filter by file format (`books.format`, e.g. `cbz`, `cbr`, `epub`, - /// `pdf`). Distinct from `BookType`, which classifies content (comic, - /// manga, novel, ...). - Format { format: FieldOperator }, - /// Filter by page count (`books.page_count`). - PageCount { - #[serde(rename = "pageCount")] - page_count: NumberOperator, - }, - /// Filter by date the book was added to the library (`books.created_at`). - DateAdded { - #[serde(rename = "dateAdded")] - date_added: DateOperator, - }, -} +pub use crate::models::filter::{ + BookCondition, BoolOperator, DateOperator, FieldOperator, NumberOperator, SeriesCondition, + UuidOperator, +}; /// Request body for POST /series/list /// @@ -311,6 +54,7 @@ pub struct BookListRequest { #[cfg(test)] mod tests { use super::*; + use uuid::Uuid; #[test] fn test_simple_genre_condition_serialization() { diff --git a/src/api/routes/v1/dto/series.rs b/src/api/routes/v1/dto/series.rs index cc4b6b9b..1f08c4b9 100644 --- a/src/api/routes/v1/dto/series.rs +++ b/src/api/routes/v1/dto/series.rs @@ -1,179 +1,12 @@ -use std::fmt; -use std::str::FromStr; - use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use utoipa::ToSchema; use super::common::PaginatedResponse; -/// Sort direction for list queries -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, ToSchema)] -#[serde(rename_all = "lowercase")] -pub enum SortDirection { - #[default] - Asc, - Desc, -} - -impl fmt::Display for SortDirection { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - SortDirection::Asc => write!(f, "asc"), - SortDirection::Desc => write!(f, "desc"), - } - } -} - -impl FromStr for SortDirection { - type Err = String; - - fn from_str(s: &str) -> Result { - match s.to_lowercase().as_str() { - "asc" => Ok(SortDirection::Asc), - "desc" => Ok(SortDirection::Desc), - _ => Err(format!("Invalid sort direction: {}", s)), - } - } -} - -/// Sort field options for series list queries -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, ToSchema)] -#[serde(rename_all = "snake_case")] -pub enum SeriesSortField { - /// Sort by series name (uses title_sort if available, otherwise title) - #[default] - Name, - /// Sort by date added to library - DateAdded, - /// Sort by last update time - DateUpdated, - /// Sort by release year - ReleaseDate, - /// Sort by last read time (user-specific) - DateRead, - /// Sort by number of books in the series - BookCount, - /// Sort by user rating (user-specific) - Rating, - /// Sort by community average rating - CommunityRating, - /// Sort by external rating (highest external source rating) - ExternalRating, - /// Sort by fuzzy-search relevance score. Only meaningful when a - /// `fullTextSearch` query is present and `search.fuzzy.enabled` is on; - /// otherwise handlers fall back to the natural default (`Name`). - Relevance, -} - -impl fmt::Display for SeriesSortField { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - SeriesSortField::Name => write!(f, "name"), - SeriesSortField::DateAdded => write!(f, "date_added"), - SeriesSortField::DateUpdated => write!(f, "date_updated"), - SeriesSortField::ReleaseDate => write!(f, "release_date"), - SeriesSortField::DateRead => write!(f, "date_read"), - SeriesSortField::BookCount => write!(f, "book_count"), - SeriesSortField::Rating => write!(f, "rating"), - SeriesSortField::CommunityRating => write!(f, "community_rating"), - SeriesSortField::ExternalRating => write!(f, "external_rating"), - SeriesSortField::Relevance => write!(f, "relevance"), - } - } -} - -impl FromStr for SeriesSortField { - type Err = String; - - fn from_str(s: &str) -> Result { - match s.to_lowercase().as_str() { - "name" => Ok(SeriesSortField::Name), - "date_added" | "created_at" => Ok(SeriesSortField::DateAdded), - "date_updated" | "updated_at" => Ok(SeriesSortField::DateUpdated), - "release_date" | "year" => Ok(SeriesSortField::ReleaseDate), - "date_read" => Ok(SeriesSortField::DateRead), - "book_count" => Ok(SeriesSortField::BookCount), - "rating" | "user_rating" => Ok(SeriesSortField::Rating), - "community_rating" | "avg_rating" => Ok(SeriesSortField::CommunityRating), - "external_rating" => Ok(SeriesSortField::ExternalRating), - "relevance" | "score" => Ok(SeriesSortField::Relevance), - _ => Err(format!("Invalid sort field: {}", s)), - } - } -} - -/// Parsed sort parameter for series queries -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct SeriesSortParam { - pub field: SeriesSortField, - pub direction: SortDirection, -} - -impl Default for SeriesSortParam { - fn default() -> Self { - Self { - field: SeriesSortField::Name, - direction: SortDirection::Asc, - } - } -} - -#[allow(dead_code)] // Public API for series sorting - used in query parsing -impl SeriesSortParam { - pub fn new(field: SeriesSortField, direction: SortDirection) -> Self { - Self { field, direction } - } - - /// Parse from "field,direction" format (e.g., "name,asc"). - /// - /// "relevance" (with or without a direction) is accepted as a shorthand - /// that pairs with a `fullTextSearch` query. - pub fn parse(s: &str) -> Self { - let trimmed = s.trim(); - if trimmed.eq_ignore_ascii_case("relevance") || trimmed.eq_ignore_ascii_case("score") { - return Self { - field: SeriesSortField::Relevance, - direction: SortDirection::Desc, - }; - } - - let parts: Vec<&str> = trimmed.split(',').collect(); - if parts.len() != 2 { - return Self::default(); - } - - let field = SeriesSortField::from_str(parts[0]).unwrap_or_default(); - let direction = SortDirection::from_str(parts[1]).unwrap_or_default(); - - Self { field, direction } - } - - /// Check if this sort requires user-specific data (e.g., read progress) - pub fn requires_user_context(&self) -> bool { - matches!( - self.field, - SeriesSortField::DateRead | SeriesSortField::Rating - ) - } - - /// Check if this sort requires aggregation - pub fn requires_aggregation(&self) -> bool { - matches!( - self.field, - SeriesSortField::BookCount - | SeriesSortField::Rating - | SeriesSortField::CommunityRating - | SeriesSortField::ExternalRating - ) - } -} - -impl fmt::Display for SeriesSortParam { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{},{}", self.field, self.direction) - } -} +// Sort parameters live in `crate::models::sort` so db repositories can take +// typed sort params without depending on the api layer. +pub use crate::models::sort::{SeriesSortField, SeriesSortParam, SortDirection}; /// Series data transfer object #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] diff --git a/src/commands/serve.rs b/src/commands/serve.rs index 85139794..02d43aed 100644 --- a/src/commands/serve.rs +++ b/src/commands/serve.rs @@ -339,13 +339,18 @@ pub async fn serve_command(config_path: PathBuf) -> anyhow::Result<()> { // set up by `TaskWorker::run_task`, not through a manager-held one. // See `crate::events::with_recording_broadcaster`. info!("Initializing plugin manager..."); + // Wrap the scheduler in the services-layer trait so plugin handles can + // trigger reconciles without holding the concrete scheduler type. + let scheduler_handle: crate::services::scheduler_handle::SharedSchedulerReconciler = Arc::new( + crate::scheduler::LockedSchedulerReconciler::new(scheduler.clone()), + ); let plugin_manager = Arc::new( crate::services::plugin::PluginManager::with_defaults(Arc::new( db.sea_orm_connection().clone(), )) .with_metrics_service(plugin_metrics_service.clone()) .with_plugin_file_storage(plugin_file_storage.clone()) - .with_scheduler(scheduler.clone()), + .with_scheduler(scheduler_handle), ); // Load enabled plugins from database match plugin_manager.load_all().await { diff --git a/src/db/entities/plugins.rs b/src/db/entities/plugins.rs index 6eeda350..cc2116df 100644 --- a/src/db/entities/plugins.rs +++ b/src/db/entities/plugins.rs @@ -756,8 +756,8 @@ impl Model { } /// Parse the scopes JSON array into a Vec - pub fn scopes_vec(&self) -> Vec { - use crate::services::plugin::protocol::PluginScope; + pub fn scopes_vec(&self) -> Vec { + use crate::models::plugin::PluginScope; self.scopes .as_array() @@ -770,7 +770,7 @@ impl Model { } /// Check if the plugin supports a specific scope - pub fn has_scope(&self, scope: &crate::services::plugin::protocol::PluginScope) -> bool { + pub fn has_scope(&self, scope: &crate::models::plugin::PluginScope) -> bool { self.scopes_vec().contains(scope) } @@ -838,7 +838,7 @@ impl Model { } /// Get the cached manifest if available - pub fn cached_manifest(&self) -> Option { + pub fn cached_manifest(&self) -> Option { self.manifest .as_ref() .and_then(|m| serde_json::from_value(m.clone()).ok()) diff --git a/src/db/entities/users.rs b/src/db/entities/users.rs index db101116..1be76861 100644 --- a/src/db/entities/users.rs +++ b/src/db/entities/users.rs @@ -3,7 +3,7 @@ use sea_orm::entity::prelude::*; use serde::{Deserialize, Serialize}; use uuid::Uuid; -use crate::api::permissions::UserRole; +use crate::models::permissions::UserRole; #[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)] #[sea_orm(table_name = "users")] diff --git a/src/db/repositories/book.rs b/src/db/repositories/book.rs index df0e01f7..3fd99d29 100644 --- a/src/db/repositories/book.rs +++ b/src/db/repositories/book.rs @@ -912,15 +912,14 @@ impl BookRepository { pub async fn list_by_ids_sorted( db: &DatabaseConnection, ids: &[Uuid], - sort: &crate::api::routes::v1::dto::book::BookSortParam, + sort: &crate::models::sort::BookSortParam, user_id: Option, include_deleted: bool, offset: u64, limit: u64, ) -> Result<(Vec, u64)> { - use crate::api::routes::v1::dto::book::BookSortField; - use crate::api::routes::v1::dto::series::SortDirection; use crate::db::entities::{book_metadata, read_progress, series, series_metadata}; + use crate::models::sort::{BookSortField, SortDirection}; use sea_orm::{Condition, JoinType}; if ids.is_empty() { @@ -1194,14 +1193,13 @@ impl BookRepository { pub async fn list_by_library_sorted( db: &DatabaseConnection, library_id: Uuid, - sort: &crate::api::routes::v1::dto::book::BookSortParam, + sort: &crate::models::sort::BookSortParam, include_deleted: bool, page: u64, page_size: u64, ) -> Result<(Vec, u64)> { - use crate::api::routes::v1::dto::book::BookSortField; - use crate::api::routes::v1::dto::series::SortDirection; use crate::db::entities::{book_metadata, series, series_metadata}; + use crate::models::sort::{BookSortField, SortDirection}; use sea_orm::JoinType; // Build base query diff --git a/src/db/repositories/library.rs b/src/db/repositories/library.rs index aaac7a9a..f4557502 100644 --- a/src/db/repositories/library.rs +++ b/src/db/repositories/library.rs @@ -348,8 +348,8 @@ impl LibraryRepository { /// Returns an empty vector if no rules are configured or if parsing fails. pub fn get_preprocessing_rules( library: &libraries::Model, - ) -> Vec { - use crate::services::metadata::preprocessing::parse_preprocessing_rules; + ) -> Vec { + use crate::models::preprocessing::parse_preprocessing_rules; match parse_preprocessing_rules(library.title_preprocessing_rules.as_deref()) { Ok(rules) => rules, @@ -370,8 +370,8 @@ impl LibraryRepository { /// Returns None if no conditions are configured or if parsing fails. pub fn get_auto_match_conditions( library: &libraries::Model, - ) -> Option { - use crate::services::metadata::preprocessing::parse_auto_match_conditions; + ) -> Option { + use crate::models::preprocessing::parse_auto_match_conditions; match parse_auto_match_conditions(library.auto_match_conditions.as_deref()) { Ok(conditions) => conditions, @@ -966,7 +966,7 @@ mod tests { #[tokio::test] async fn test_get_auto_match_conditions_valid() { - use crate::services::metadata::preprocessing::{ConditionMode, ConditionOperator}; + use crate::models::preprocessing::{ConditionMode, ConditionOperator}; let (db, _temp_dir) = create_test_db().await; diff --git a/src/db/repositories/plugin_failures.rs b/src/db/repositories/plugin_failures.rs index 1b3c8b3c..58d2edd4 100644 --- a/src/db/repositories/plugin_failures.rs +++ b/src/db/repositories/plugin_failures.rs @@ -293,7 +293,7 @@ mod tests { use crate::db::entities::plugin_failures::error_codes; use crate::db::repositories::PluginsRepository; use crate::db::test_helpers::setup_test_db; - use crate::services::plugin::protocol::PluginScope; + use crate::models::plugin::PluginScope; use std::env; use tokio::time::sleep; diff --git a/src/db/repositories/plugins.rs b/src/db/repositories/plugins.rs index 867a7c6d..bf96eb93 100644 --- a/src/db/repositories/plugins.rs +++ b/src/db/repositories/plugins.rs @@ -15,9 +15,9 @@ #![allow(dead_code)] use crate::db::entities::plugins::{self, Entity as Plugins, PluginPermission}; +use crate::models::plugin::{PluginManifest, PluginScope}; use crate::observability::repo::db_system_str; -use crate::services::CredentialEncryption; -use crate::services::plugin::protocol::{PluginManifest, PluginScope}; +use crate::utils::credential_encryption::CredentialEncryption; use anyhow::{Result, anyhow}; use chrono::Utc; use sea_orm::*; @@ -735,8 +735,8 @@ impl PluginsRepository { /// Returns an empty vector if no rules are configured or if parsing fails. pub fn get_search_preprocessing_rules( plugin: &plugins::Model, - ) -> Vec { - use crate::services::metadata::preprocessing::parse_preprocessing_rules; + ) -> Vec { + use crate::models::preprocessing::parse_preprocessing_rules; match parse_preprocessing_rules(plugin.search_preprocessing_rules.as_deref()) { Ok(rules) => rules, @@ -757,8 +757,8 @@ impl PluginsRepository { /// Returns None if no conditions are configured or if parsing fails. pub fn get_auto_match_conditions( plugin: &plugins::Model, - ) -> Option { - use crate::services::metadata::preprocessing::parse_auto_match_conditions; + ) -> Option { + use crate::models::preprocessing::parse_auto_match_conditions; match parse_auto_match_conditions(plugin.auto_match_conditions.as_deref()) { Ok(conditions) => conditions, diff --git a/src/db/repositories/release_ledger.rs b/src/db/repositories/release_ledger.rs index 92d3620b..5fe2132c 100644 --- a/src/db/repositories/release_ledger.rs +++ b/src/db/repositories/release_ledger.rs @@ -18,7 +18,7 @@ use uuid::Uuid; use crate::db::entities::release_ledger::{ self, Entity as ReleaseLedger, Model as ReleaseLedgerRow, state, }; -use crate::services::release::candidate::{NumericSpan, normalize_spans, primary_value}; +use crate::models::release::{NumericSpan, normalize_spans, primary_value}; /// New-row payload. Keys plus payload fields. /// diff --git a/src/db/repositories/series.rs b/src/db/repositories/series.rs index d3d75531..a02d6581 100644 --- a/src/db/repositories/series.rs +++ b/src/db/repositories/series.rs @@ -13,12 +13,12 @@ use sea_orm::{ }; use uuid::Uuid; -use crate::api::routes::v1::dto::series::{SeriesSortField, SeriesSortParam, SortDirection}; use crate::db::entities::{ book_metadata, books, prelude::*, read_progress, series, series_external_ratings, series_metadata, user_series_ratings, }; use crate::events::{EntityChangeEvent, EntityEvent, EventBroadcaster}; +use crate::models::sort::{SeriesSortField, SeriesSortParam, SortDirection}; use crate::observability::repo::db_system_str; use crate::utils::normalize_for_search; use std::sync::Arc; @@ -2171,8 +2171,8 @@ impl SeriesRepository { pub async fn get_owned_release_keys_for_series( db: &DatabaseConnection, series_id: Uuid, - ) -> Result { - use crate::services::release::auto_ignore::OwnedReleaseKeys; + ) -> Result { + use crate::models::release::OwnedReleaseKeys; #[derive(Debug, FromQueryResult)] struct KeyRow { diff --git a/src/db/repositories/task.rs b/src/db/repositories/task.rs index 21b8b1a5..0fb65434 100644 --- a/src/db/repositories/task.rs +++ b/src/db/repositories/task.rs @@ -11,8 +11,7 @@ use uuid::Uuid; use crate::db::entities::{ book_metadata, books, libraries, prelude::*, series, series_metadata, tasks, }; -use crate::tasks::error::DEFAULT_MAX_RESCHEDULES; -use crate::tasks::types::{TaskStats, TaskType}; +use crate::models::task::{DEFAULT_MAX_RESCHEDULES, TaskStats, TaskType}; /// Task row enriched with the resolved title of its target (book, series, or library). /// @@ -1139,7 +1138,7 @@ impl TaskRepository { /// Get queue statistics pub async fn get_stats(db: &DatabaseConnection) -> Result { - use crate::tasks::types::TaskTypeStats; + use crate::models::task::TaskTypeStats; use std::collections::HashMap; // Get all tasks to calculate both aggregate and per-type stats diff --git a/src/db/repositories/user_plugins.rs b/src/db/repositories/user_plugins.rs index d067331f..9ede64f0 100644 --- a/src/db/repositories/user_plugins.rs +++ b/src/db/repositories/user_plugins.rs @@ -16,7 +16,7 @@ #![allow(dead_code)] use crate::db::entities::user_plugins::{self, Entity as UserPlugins}; -use crate::services::CredentialEncryption; +use crate::utils::credential_encryption::CredentialEncryption; use anyhow::{Result, anyhow}; use chrono::{DateTime, Utc}; use sea_orm::*; diff --git a/src/models/filter.rs b/src/models/filter.rs new file mode 100644 index 00000000..e71777ea --- /dev/null +++ b/src/models/filter.rs @@ -0,0 +1,275 @@ +//! Filter operator types shared between the api DTOs and the services +//! filter engine. Repositories and services need to speak this vocabulary +//! without depending on the api layer. + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; +use uuid::Uuid; + +/// Operators for string and equality comparisons +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(tag = "operator", rename_all = "camelCase")] +pub enum FieldOperator { + /// Exact match + Is { value: String }, + /// Not equal + IsNot { value: String }, + /// Field is null/empty + IsNull, + /// Field is not null/empty + IsNotNull, + /// String contains (case-insensitive) + Contains { value: String }, + /// String does not contain (case-insensitive) + DoesNotContain { value: String }, + /// String starts with (case-insensitive) + BeginsWith { value: String }, + /// String ends with (case-insensitive) + EndsWith { value: String }, +} + +/// Operators for UUID comparisons (library_id, series_id, etc.) +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(tag = "operator", rename_all = "camelCase")] +pub enum UuidOperator { + /// Exact match + Is { value: Uuid }, + /// Not equal + IsNot { value: Uuid }, +} + +/// Operators for boolean comparisons +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(tag = "operator", rename_all = "camelCase")] +pub enum BoolOperator { + /// Is true + IsTrue, + /// Is false + IsFalse, +} + +/// Operators for numeric comparisons (year, page count, etc.). +/// +/// Values are deserialized as `i64` so the same operator can target either +/// `INTEGER` or `BIGINT` columns. Implementations downcast as needed. +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(tag = "operator", rename_all = "camelCase")] +pub enum NumberOperator { + /// Equal to value + Eq { value: i64 }, + /// Not equal to value + Ne { value: i64 }, + /// Greater than value (strict) + Gt { value: i64 }, + /// Greater than or equal to value + Gte { value: i64 }, + /// Less than value (strict) + Lt { value: i64 }, + /// Less than or equal to value + Lte { value: i64 }, + /// Inclusive range, `min <= field <= max`. Either bound may be omitted to + /// model open-ended ranges (e.g. "year >= 2000"). + Between { + #[serde(default, skip_serializing_if = "Option::is_none")] + min: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + max: Option, + }, + /// Field is null + IsNull, + /// Field is not null + IsNotNull, +} + +/// Operators for date/timestamp comparisons. +/// +/// Values are RFC 3339 / ISO 8601 timestamps. For range comparisons either +/// bound may be omitted to express an open-ended range. +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(tag = "operator", rename_all = "camelCase")] +pub enum DateOperator { + /// Strictly after the given timestamp + After { value: DateTime }, + /// Strictly before the given timestamp + Before { value: DateTime }, + /// On or after the given timestamp + OnOrAfter { value: DateTime }, + /// On or before the given timestamp + OnOrBefore { value: DateTime }, + /// Inclusive between range. Either bound may be omitted. + Between { + #[serde(default, skip_serializing_if = "Option::is_none")] + start: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + end: Option>, + }, + /// Field is null + IsNull, + /// Field is not null + IsNotNull, +} + +/// Series-level search conditions +/// +/// Conditions can be composed using `allOf` (AND) and `anyOf` (OR). +/// Uses untagged enum for cleaner JSON without explicit type field. +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(untagged)] +pub enum SeriesCondition { + /// All conditions must match (AND) + AllOf { + #[serde(rename = "allOf")] + #[schema(no_recursion)] + all_of: Vec, + }, + /// Any condition must match (OR) + AnyOf { + #[serde(rename = "anyOf")] + #[schema(no_recursion)] + any_of: Vec, + }, + /// Filter by library ID + LibraryId { + #[serde(rename = "libraryId")] + library_id: UuidOperator, + }, + /// Filter by genre name + Genre { genre: FieldOperator }, + /// Filter by tag name + Tag { tag: FieldOperator }, + /// Filter by series status (ongoing, ended, hiatus, etc.) + Status { status: FieldOperator }, + /// Filter by publisher + Publisher { publisher: FieldOperator }, + /// Filter by language + Language { language: FieldOperator }, + /// Filter by series title (`series_metadata.title`) + Title { title: FieldOperator }, + /// Filter by series title_sort field (used for alphabetical filtering) + TitleSort { + #[serde(rename = "titleSort")] + title_sort: FieldOperator, + }, + /// Filter by read status (unread, in_progress, read) + ReadStatus { + #[serde(rename = "readStatus")] + read_status: FieldOperator, + }, + /// Filter by sharing tag name + SharingTag { + #[serde(rename = "sharingTag")] + sharing_tag: FieldOperator, + }, + /// Filter by series completion status (complete/incomplete based on book_count vs total_volume_count) + Completion { completion: BoolOperator }, + /// Filter by whether the series has an external source ID linked + HasExternalSourceId { + #[serde(rename = "hasExternalSourceId")] + has_external_source_id: BoolOperator, + }, + /// Filter by whether the series has a rating from the current user + HasUserRating { + #[serde(rename = "hasUserRating")] + has_user_rating: BoolOperator, + }, + /// Filter by whether release tracking is enabled for the series. + /// + /// `IsTrue` returns only series whose `series_tracking.tracked` flag is + /// `true`. `IsFalse` returns everything else, including series with no + /// `series_tracking` row at all (the common case for a fresh library). + IsTracked { + #[serde(rename = "isTracked")] + is_tracked: BoolOperator, + }, + /// Filter by release year (from `series_metadata.year`). + Year { year: NumberOperator }, + /// Filter by author (substring match on `series_metadata.authors_json`). + /// + /// The match is performed against the raw JSON text. It is tolerant of + /// both string-list and object-list shapes but may incidentally match + /// other fields (e.g. `role`); callers wanting strict matching should + /// pre-quote the value. + Author { author: FieldOperator }, + /// Filter by the series' folder path (`series.path`). Useful for matching + /// series under a given directory. + Path { path: FieldOperator }, + /// Filter by date the series was added to the library + /// (`series.created_at`). + DateAdded { + #[serde(rename = "dateAdded")] + date_added: DateOperator, + }, +} + +/// Book-level search conditions +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(untagged)] +pub enum BookCondition { + /// All conditions must match (AND) + AllOf { + #[serde(rename = "allOf")] + #[schema(no_recursion)] + all_of: Vec, + }, + /// Any condition must match (OR) + AnyOf { + #[serde(rename = "anyOf")] + #[schema(no_recursion)] + any_of: Vec, + }, + /// Filter by library ID + LibraryId { + #[serde(rename = "libraryId")] + library_id: UuidOperator, + }, + /// Filter by series ID + SeriesId { + #[serde(rename = "seriesId")] + series_id: UuidOperator, + }, + /// Filter by genre name (from parent series) + Genre { genre: FieldOperator }, + /// Filter by tag name (from parent series) + Tag { tag: FieldOperator }, + /// Filter by book title (`book_metadata.title`) + Title { title: FieldOperator }, + /// Filter by book title_sort field (`book_metadata.title_sort`, + /// used for alphabetical filtering) + TitleSort { + #[serde(rename = "titleSort")] + title_sort: FieldOperator, + }, + /// Filter by read status (unread, in_progress, read) + ReadStatus { + #[serde(rename = "readStatus")] + read_status: FieldOperator, + }, + /// Filter by books with analysis errors + HasError { + #[serde(rename = "hasError")] + has_error: BoolOperator, + }, + /// Filter by book type (comic, manga, novel, etc.) + BookType { + #[serde(rename = "bookType")] + book_type: FieldOperator, + }, + /// Filter by the book's file path (`books.path`). Useful for matching + /// books under a given directory or with a specific filename fragment. + Path { path: FieldOperator }, + /// Filter by file format (`books.format`, e.g. `cbz`, `cbr`, `epub`, + /// `pdf`). Distinct from `BookType`, which classifies content (comic, + /// manga, novel, ...). + Format { format: FieldOperator }, + /// Filter by page count (`books.page_count`). + PageCount { + #[serde(rename = "pageCount")] + page_count: NumberOperator, + }, + /// Filter by date the book was added to the library (`books.created_at`). + DateAdded { + #[serde(rename = "dateAdded")] + date_added: DateOperator, + }, +} diff --git a/src/models/mod.rs b/src/models/mod.rs new file mode 100644 index 00000000..bb5b2790 --- /dev/null +++ b/src/models/mod.rs @@ -0,0 +1,18 @@ +//! Cross-layer data models. +//! +//! Types in this module are shared between the api, db, services, tasks, and +//! utils layers without anyone needing to import "up the stack". Anything that +//! both a repository and an API DTO need to reference belongs here so the +//! direction of the dependency stays one-way (consumers depend on `models`, +//! `models` depends on nothing else inside the crate beyond `utils`). + +pub mod filter; +pub mod permissions; +pub mod plugin; +pub mod preprocessing; +pub mod release; +pub mod sort; +pub mod strategies; +pub mod task; + +pub use strategies::*; diff --git a/src/models/permissions.rs b/src/models/permissions.rs new file mode 100644 index 00000000..e31848f1 --- /dev/null +++ b/src/models/permissions.rs @@ -0,0 +1,527 @@ +use serde::{Deserialize, Serialize}; +use std::collections::HashSet; +use std::fmt; +use std::str::FromStr; +use utoipa::ToSchema; + +/// User roles for role-based access control (RBAC) +/// +/// Roles define a base set of permissions that users inherit. +/// Custom permissions can be added on top of role permissions (union behavior). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, ToSchema, Default)] +#[serde(rename_all = "lowercase")] +pub enum UserRole { + /// Basic read access - can browse and read content + #[default] + Reader, + /// Content management - can modify series, books, run scans + Maintainer, + /// Full system access - can manage users, system settings + Admin, +} + +impl UserRole { + /// Get the permission set associated with this role + pub fn permissions(&self) -> &'static HashSet { + match self { + UserRole::Reader => &READER_PERMISSIONS, + UserRole::Maintainer => &MAINTAINER_PERMISSIONS, + UserRole::Admin => &ADMIN_PERMISSIONS, + } + } + + /// Check if this role can assign another role to a user + /// + /// Admin can assign any role, Maintainer can only assign Reader, + /// Reader cannot assign roles. + #[allow(dead_code)] // Reserved for the user role assignment API + pub fn can_assign(&self, target: UserRole) -> bool { + match self { + UserRole::Admin => true, + UserRole::Maintainer => target == UserRole::Reader, + UserRole::Reader => false, + } + } + + /// Returns all possible role values + #[allow(dead_code)] // Reserved for the user role assignment API + pub fn all() -> &'static [UserRole] { + &[UserRole::Reader, UserRole::Maintainer, UserRole::Admin] + } +} + +impl fmt::Display for UserRole { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + UserRole::Reader => write!(f, "reader"), + UserRole::Maintainer => write!(f, "maintainer"), + UserRole::Admin => write!(f, "admin"), + } + } +} + +impl FromStr for UserRole { + type Err = String; + + fn from_str(s: &str) -> Result { + match s.to_lowercase().as_str() { + "reader" => Ok(UserRole::Reader), + "maintainer" => Ok(UserRole::Maintainer), + "admin" => Ok(UserRole::Admin), + _ => Err(format!("Unknown role: {}", s)), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "kebab-case")] +pub enum Permission { + // Libraries + LibrariesRead, + LibrariesWrite, + LibrariesDelete, + + // Series + SeriesRead, + SeriesWrite, + SeriesDelete, + + // Books + BooksRead, + BooksWrite, + BooksDelete, + + // Pages (image serving) + PagesRead, + + // Progress (reading progress tracking) + ProgressRead, + ProgressWrite, + + // Users (admin only) + UsersRead, + UsersWrite, + UsersDelete, + + // API Keys (admin only) + ApiKeysRead, + ApiKeysWrite, + ApiKeysDelete, + + // Tasks + TasksRead, + TasksWrite, + + // Plugins (admin configuration) + PluginsManage, + + // System + SystemHealth, + SystemAdmin, +} + +#[allow(dead_code)] // Public API for permission string representation +impl Permission { + /// Convert permission to string format: "resource:action" + pub fn as_str(&self) -> &'static str { + match self { + Permission::LibrariesRead => "libraries:read", + Permission::LibrariesWrite => "libraries:write", + Permission::LibrariesDelete => "libraries:delete", + Permission::SeriesRead => "series:read", + Permission::SeriesWrite => "series:write", + Permission::SeriesDelete => "series:delete", + Permission::BooksRead => "books:read", + Permission::BooksWrite => "books:write", + Permission::BooksDelete => "books:delete", + Permission::PagesRead => "pages:read", + Permission::ProgressRead => "progress:read", + Permission::ProgressWrite => "progress:write", + Permission::UsersRead => "users:read", + Permission::UsersWrite => "users:write", + Permission::UsersDelete => "users:delete", + Permission::ApiKeysRead => "api-keys:read", + Permission::ApiKeysWrite => "api-keys:write", + Permission::ApiKeysDelete => "api-keys:delete", + Permission::TasksRead => "tasks:read", + Permission::TasksWrite => "tasks:write", + Permission::PluginsManage => "plugins:manage", + Permission::SystemHealth => "system:health", + Permission::SystemAdmin => "system:admin", + } + } +} + +impl FromStr for Permission { + type Err = String; + + fn from_str(s: &str) -> Result { + match s { + "libraries:read" => Ok(Permission::LibrariesRead), + "libraries:write" => Ok(Permission::LibrariesWrite), + "libraries:delete" => Ok(Permission::LibrariesDelete), + "series:read" => Ok(Permission::SeriesRead), + "series:write" => Ok(Permission::SeriesWrite), + "series:delete" => Ok(Permission::SeriesDelete), + "books:read" => Ok(Permission::BooksRead), + "books:write" => Ok(Permission::BooksWrite), + "books:delete" => Ok(Permission::BooksDelete), + "pages:read" => Ok(Permission::PagesRead), + "progress:read" => Ok(Permission::ProgressRead), + "progress:write" => Ok(Permission::ProgressWrite), + "users:read" => Ok(Permission::UsersRead), + "users:write" => Ok(Permission::UsersWrite), + "users:delete" => Ok(Permission::UsersDelete), + "api-keys:read" => Ok(Permission::ApiKeysRead), + "api-keys:write" => Ok(Permission::ApiKeysWrite), + "api-keys:delete" => Ok(Permission::ApiKeysDelete), + "tasks:read" => Ok(Permission::TasksRead), + "tasks:write" => Ok(Permission::TasksWrite), + "plugins:manage" => Ok(Permission::PluginsManage), + "system:health" => Ok(Permission::SystemHealth), + "system:admin" => Ok(Permission::SystemAdmin), + _ => Err(format!("Unknown permission: {}", s)), + } + } +} + +/// Parse permissions from JSON string +#[allow(dead_code)] // Public API for permission parsing +pub fn parse_permissions(json: &str) -> Result, serde_json::Error> { + let perms: Vec = serde_json::from_str(json)?; + Ok(perms.into_iter().collect()) +} + +/// Serialize permissions to JSON string +pub fn serialize_permissions(permissions: &HashSet) -> String { + let perms: Vec = permissions.iter().cloned().collect(); + serde_json::to_string(&perms).unwrap_or_else(|_| "[]".to_string()) +} + +// Preset permission sets +lazy_static::lazy_static! { + /// Read-only permissions (basic read access - legacy, kept for backwards compatibility) + pub static ref READONLY_PERMISSIONS: HashSet = { + let mut set = HashSet::new(); + set.insert(Permission::LibrariesRead); + set.insert(Permission::SeriesRead); + set.insert(Permission::BooksRead); + set.insert(Permission::PagesRead); + set.insert(Permission::ProgressRead); + set.insert(Permission::ProgressWrite); + set.insert(Permission::SystemHealth); + set + }; + + /// Reader role permissions + /// + /// Reader can: + /// - Browse libraries, series, and books + /// - Read pages/content + /// - Manage their own API keys + /// - View system health + pub static ref READER_PERMISSIONS: HashSet = { + let mut set = HashSet::new(); + // Content access + set.insert(Permission::LibrariesRead); + set.insert(Permission::SeriesRead); + set.insert(Permission::BooksRead); + set.insert(Permission::PagesRead); + // Progress tracking + set.insert(Permission::ProgressRead); + set.insert(Permission::ProgressWrite); + // Own API keys + set.insert(Permission::ApiKeysRead); + set.insert(Permission::ApiKeysWrite); + set.insert(Permission::ApiKeysDelete); + // System + set.insert(Permission::SystemHealth); + set + }; + + /// Maintainer role permissions + /// + /// Maintainer can do everything Reader can, plus: + /// - Create/modify libraries (but not delete) + /// - Create/modify/delete series + /// - Create/modify/delete books + /// - View and manage tasks + pub static ref MAINTAINER_PERMISSIONS: HashSet = { + let mut set = READER_PERMISSIONS.clone(); + // Libraries (create/modify, but not delete) + set.insert(Permission::LibrariesWrite); + // Series (full control) + set.insert(Permission::SeriesWrite); + set.insert(Permission::SeriesDelete); + // Books (full control) + set.insert(Permission::BooksWrite); + set.insert(Permission::BooksDelete); + // Tasks (view and manage) + set.insert(Permission::TasksRead); + set.insert(Permission::TasksWrite); + set + }; + + /// Admin role permissions (all permissions) + /// + /// Admin can do everything, including: + /// - Delete libraries + /// - Manage users + /// - Manage plugins + /// - System administration + pub static ref ADMIN_PERMISSIONS: HashSet = { + let mut set = MAINTAINER_PERMISSIONS.clone(); + // Libraries (full control including delete) + set.insert(Permission::LibrariesDelete); + // Users (full control) + set.insert(Permission::UsersRead); + set.insert(Permission::UsersWrite); + set.insert(Permission::UsersDelete); + // Plugins (configuration) + set.insert(Permission::PluginsManage); + // System admin + set.insert(Permission::SystemAdmin); + set + }; +} + +#[cfg(test)] +mod tests { + use super::*; + + // ============== Permission tests ============== + + #[test] + fn test_permission_as_str() { + assert_eq!(Permission::LibrariesRead.as_str(), "libraries:read"); + assert_eq!(Permission::BooksWrite.as_str(), "books:write"); + assert_eq!(Permission::PluginsManage.as_str(), "plugins:manage"); + assert_eq!(Permission::SystemAdmin.as_str(), "system:admin"); + } + + #[test] + fn test_permission_from_str() { + assert_eq!( + Permission::from_str("libraries:read").unwrap(), + Permission::LibrariesRead + ); + assert_eq!( + Permission::from_str("books:write").unwrap(), + Permission::BooksWrite + ); + assert_eq!( + Permission::from_str("plugins:manage").unwrap(), + Permission::PluginsManage + ); + assert!(Permission::from_str("invalid:permission").is_err()); + } + + #[test] + fn test_parse_permissions() { + let json = r#"["libraries-read", "books-read", "pages-read"]"#; + let perms = parse_permissions(json).unwrap(); + + assert_eq!(perms.len(), 3); + assert!(perms.contains(&Permission::LibrariesRead)); + assert!(perms.contains(&Permission::BooksRead)); + assert!(perms.contains(&Permission::PagesRead)); + } + + #[test] + fn test_serialize_permissions() { + let mut perms = HashSet::new(); + perms.insert(Permission::LibrariesRead); + perms.insert(Permission::BooksRead); + + let json = serialize_permissions(&perms); + let parsed = parse_permissions(&json).unwrap(); + + assert_eq!(parsed.len(), 2); + assert!(parsed.contains(&Permission::LibrariesRead)); + assert!(parsed.contains(&Permission::BooksRead)); + } + + #[test] + fn test_readonly_permissions() { + assert!(READONLY_PERMISSIONS.contains(&Permission::LibrariesRead)); + assert!(READONLY_PERMISSIONS.contains(&Permission::BooksRead)); + assert!(!READONLY_PERMISSIONS.contains(&Permission::LibrariesWrite)); + assert_eq!(READONLY_PERMISSIONS.len(), 7); + } + + // ============== Role permission preset tests ============== + + #[test] + fn test_reader_permissions() { + // Reader has basic content access + assert!(READER_PERMISSIONS.contains(&Permission::LibrariesRead)); + assert!(READER_PERMISSIONS.contains(&Permission::SeriesRead)); + assert!(READER_PERMISSIONS.contains(&Permission::BooksRead)); + assert!(READER_PERMISSIONS.contains(&Permission::PagesRead)); + // Reader has API key management for themselves + assert!(READER_PERMISSIONS.contains(&Permission::ApiKeysRead)); + assert!(READER_PERMISSIONS.contains(&Permission::ApiKeysWrite)); + assert!(READER_PERMISSIONS.contains(&Permission::ApiKeysDelete)); + // Reader has system health + assert!(READER_PERMISSIONS.contains(&Permission::SystemHealth)); + // Reader cannot view or manage tasks + assert!(!READER_PERMISSIONS.contains(&Permission::TasksRead)); + assert!(!READER_PERMISSIONS.contains(&Permission::TasksWrite)); + // Reader can track reading progress + assert!(READER_PERMISSIONS.contains(&Permission::ProgressRead)); + assert!(READER_PERMISSIONS.contains(&Permission::ProgressWrite)); + // Reader cannot modify content + assert!(!READER_PERMISSIONS.contains(&Permission::BooksWrite)); + assert!(!READER_PERMISSIONS.contains(&Permission::SeriesWrite)); + assert!(!READER_PERMISSIONS.contains(&Permission::LibrariesWrite)); + // Reader cannot manage users or system + assert!(!READER_PERMISSIONS.contains(&Permission::UsersRead)); + assert!(!READER_PERMISSIONS.contains(&Permission::SystemAdmin)); + + assert_eq!(READER_PERMISSIONS.len(), 10); + } + + #[test] + fn test_maintainer_permissions() { + // Maintainer is a superset of Reader + for perm in READER_PERMISSIONS.iter() { + assert!( + MAINTAINER_PERMISSIONS.contains(perm), + "Maintainer missing Reader permission: {:?}", + perm + ); + } + // Maintainer can modify libraries (but not delete) + assert!(MAINTAINER_PERMISSIONS.contains(&Permission::LibrariesWrite)); + assert!(!MAINTAINER_PERMISSIONS.contains(&Permission::LibrariesDelete)); + // Maintainer can fully manage series and books + assert!(MAINTAINER_PERMISSIONS.contains(&Permission::SeriesWrite)); + assert!(MAINTAINER_PERMISSIONS.contains(&Permission::SeriesDelete)); + assert!(MAINTAINER_PERMISSIONS.contains(&Permission::BooksWrite)); + assert!(MAINTAINER_PERMISSIONS.contains(&Permission::BooksDelete)); + // Maintainer can manage tasks + assert!(MAINTAINER_PERMISSIONS.contains(&Permission::TasksWrite)); + // Maintainer cannot manage users or system admin + assert!(!MAINTAINER_PERMISSIONS.contains(&Permission::UsersRead)); + assert!(!MAINTAINER_PERMISSIONS.contains(&Permission::SystemAdmin)); + + assert_eq!(MAINTAINER_PERMISSIONS.len(), 17); + } + + #[test] + fn test_admin_permissions() { + // Admin is a superset of Maintainer + for perm in MAINTAINER_PERMISSIONS.iter() { + assert!( + ADMIN_PERMISSIONS.contains(perm), + "Admin missing Maintainer permission: {:?}", + perm + ); + } + // Admin has library delete + assert!(ADMIN_PERMISSIONS.contains(&Permission::LibrariesDelete)); + // Admin has full user management + assert!(ADMIN_PERMISSIONS.contains(&Permission::UsersRead)); + assert!(ADMIN_PERMISSIONS.contains(&Permission::UsersWrite)); + assert!(ADMIN_PERMISSIONS.contains(&Permission::UsersDelete)); + // Admin has plugin management + assert!(ADMIN_PERMISSIONS.contains(&Permission::PluginsManage)); + // Admin has system admin + assert!(ADMIN_PERMISSIONS.contains(&Permission::SystemAdmin)); + + assert_eq!(ADMIN_PERMISSIONS.len(), 23); // All permissions + } + + // ============== UserRole tests ============== + + #[test] + fn test_user_role_from_str() { + assert_eq!(UserRole::from_str("reader").unwrap(), UserRole::Reader); + assert_eq!(UserRole::from_str("Reader").unwrap(), UserRole::Reader); + assert_eq!(UserRole::from_str("READER").unwrap(), UserRole::Reader); + assert_eq!( + UserRole::from_str("maintainer").unwrap(), + UserRole::Maintainer + ); + assert_eq!(UserRole::from_str("admin").unwrap(), UserRole::Admin); + assert!(UserRole::from_str("invalid").is_err()); + } + + #[test] + fn test_user_role_display() { + assert_eq!(UserRole::Reader.to_string(), "reader"); + assert_eq!(UserRole::Maintainer.to_string(), "maintainer"); + assert_eq!(UserRole::Admin.to_string(), "admin"); + } + + #[test] + fn test_user_role_default() { + assert_eq!(UserRole::default(), UserRole::Reader); + } + + #[test] + fn test_user_role_permissions() { + assert_eq!(UserRole::Reader.permissions(), &*READER_PERMISSIONS); + assert_eq!(UserRole::Maintainer.permissions(), &*MAINTAINER_PERMISSIONS); + assert_eq!(UserRole::Admin.permissions(), &*ADMIN_PERMISSIONS); + } + + #[test] + fn test_user_role_can_assign() { + // Admin can assign any role + assert!(UserRole::Admin.can_assign(UserRole::Reader)); + assert!(UserRole::Admin.can_assign(UserRole::Maintainer)); + assert!(UserRole::Admin.can_assign(UserRole::Admin)); + + // Maintainer can only assign Reader + assert!(UserRole::Maintainer.can_assign(UserRole::Reader)); + assert!(!UserRole::Maintainer.can_assign(UserRole::Maintainer)); + assert!(!UserRole::Maintainer.can_assign(UserRole::Admin)); + + // Reader cannot assign any role + assert!(!UserRole::Reader.can_assign(UserRole::Reader)); + assert!(!UserRole::Reader.can_assign(UserRole::Maintainer)); + assert!(!UserRole::Reader.can_assign(UserRole::Admin)); + } + + #[test] + fn test_user_role_all() { + let all_roles = UserRole::all(); + assert_eq!(all_roles.len(), 3); + assert!(all_roles.contains(&UserRole::Reader)); + assert!(all_roles.contains(&UserRole::Maintainer)); + assert!(all_roles.contains(&UserRole::Admin)); + } + + #[test] + fn test_user_role_serialization() { + // Test serialization + let role = UserRole::Admin; + let json = serde_json::to_string(&role).unwrap(); + assert_eq!(json, "\"admin\""); + + // Test deserialization + let deserialized: UserRole = serde_json::from_str("\"maintainer\"").unwrap(); + assert_eq!(deserialized, UserRole::Maintainer); + } + + #[test] + fn test_role_hierarchy_is_proper_superset() { + // Each role should be a proper superset of the previous one + // Reader < Maintainer < Admin + + let reader = &*READER_PERMISSIONS; + let maintainer = &*MAINTAINER_PERMISSIONS; + let admin = &*ADMIN_PERMISSIONS; + + // Maintainer is a proper superset of Reader + assert!(reader.is_subset(maintainer)); + assert!(!maintainer.is_subset(reader)); + assert!(maintainer.len() > reader.len()); + + // Admin is a proper superset of Maintainer + assert!(maintainer.is_subset(admin)); + assert!(!admin.is_subset(maintainer)); + assert!(admin.len() > maintainer.len()); + } +} diff --git a/src/models/plugin.rs b/src/models/plugin.rs new file mode 100644 index 00000000..83e1efec --- /dev/null +++ b/src/models/plugin.rs @@ -0,0 +1,426 @@ +//! Plugin manifest and scope value types shared between the db and +//! services layers. +//! +//! The JSON-RPC wire format and the search/match DTOs live next to the plugin +//! manager in [`crate::services::plugin::protocol`]. Only the types that both +//! a repository and a service need to speak (manifest descriptors, capability +//! declarations, scope enums) live here so `db` can reference them without +//! taking a hard dependency on `services`. + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +/// Plugin manifest declared by a plugin in its `manifest.json` and cached on +/// the plugin row. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PluginManifest { + /// Unique identifier (e.g., "mangaupdates") + pub name: String, + /// Display name for UI (e.g., "MangaUpdates") + pub display_name: String, + /// Semantic version (e.g., "1.0.0") + pub version: String, + /// Description of the plugin + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, + /// Plugin author + #[serde(default, skip_serializing_if = "Option::is_none")] + pub author: Option, + /// Plugin homepage URL + #[serde(default, skip_serializing_if = "Option::is_none")] + pub homepage: Option, + + /// Protocol version this plugin implements + pub protocol_version: String, + + /// Plugin capabilities + pub capabilities: PluginCapabilities, + + /// Required credentials for this plugin + #[serde(default)] + pub required_credentials: Vec, + + /// JSON Schema for plugin-specific configuration (admin-facing) + #[serde(default, skip_serializing_if = "Option::is_none")] + pub config_schema: Option, + + /// Configuration schema for per-user settings (user-facing) + #[serde(default, skip_serializing_if = "Option::is_none")] + pub user_config_schema: Option, + + /// Plugin type: "system" (admin-only metadata) or "user" (per-user integrations) + #[serde(default)] + pub plugin_type: PluginManifestType, + + /// OAuth 2.0 configuration for user plugins that require external service authentication + #[serde(default, skip_serializing_if = "Option::is_none")] + pub oauth: Option, + + /// User-facing description shown when enabling the plugin + #[serde(default, skip_serializing_if = "Option::is_none")] + pub user_description: Option, + + /// Admin-facing setup instructions (e.g., how to create OAuth app, set client ID) + #[serde(default, skip_serializing_if = "Option::is_none")] + pub admin_setup_instructions: Option, + + /// User-facing setup instructions (e.g., how to connect or get a personal token) + #[serde(default, skip_serializing_if = "Option::is_none")] + pub user_setup_instructions: Option, + + /// URI template for searching on the plugin's website. + /// Use `` as placeholder for the URL-encoded search query. + /// Example: `https://mangabaka.org/search?sort_by=popularity_asc&q=<title>` + #[serde( + default, + skip_serializing_if = "Option::is_none", + rename = "searchURITemplate" + )] + pub search_uri_template: Option<String>, +} + +/// Content types that a metadata provider can support +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)] +#[serde(rename_all = "lowercase")] +pub enum MetadataContentType { + /// Series metadata (manga, comics, etc.) + Series, + /// Book metadata (individual books, ebooks, novels) + Book, +} + +/// Plugin capabilities +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PluginCapabilities { + /// Content types this plugin can provide metadata for + /// e.g., ["series"] or ["series", "book"] + #[serde(default)] + pub metadata_provider: Vec<MetadataContentType>, + /// Can sync user reading progress (v2) + #[serde(default)] + pub user_read_sync: bool, + /// External ID source used to match sync entries to Codex series. + /// When set, pulled sync entries are matched to series via the + /// `series_external_ids` table using this source string. + /// Uses the `api:<service>` convention, e.g. "api:anilist". + /// Only meaningful when `user_read_sync` is true. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub external_id_source: Option<String>, + /// Can provide personalized recommendations (v2) + #[serde(default)] + pub user_recommendation_provider: bool, + /// Can announce new releases (chapters/volumes) for tracked series. + /// When present, the plugin may invoke the `releases/*` reverse-RPC + /// methods. The capability struct declares the data the plugin needs + /// (aliases, external IDs) so the host can scope its responses. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub release_source: Option<ReleaseSourceCapability>, +} + +/// Release-source capability declaration. +/// +/// Plugins that want to announce releases declare this capability in their +/// manifest. The struct describes both *what* the plugin can announce and +/// *what* it needs from the host. The host uses these fields when filling +/// `releases/list_tracked` responses so plugins only see data they asked for. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ReleaseSourceCapability { + /// Source kinds this plugin exposes (e.g. `["rss-uploader"]`). + #[serde(default)] + pub kinds: Vec<ReleaseSourceKind>, + /// Whether the plugin needs title aliases (set when the plugin matches + /// by title rather than by external ID, e.g. Nyaa). + #[serde(default)] + pub requires_aliases: bool, + /// External-ID sources the plugin needs, e.g. `["mangaupdates"]` or + /// `["mangadex"]`. The host filters `series_external_ids` to these + /// sources when responding to `releases/list_tracked`. + #[serde(default)] + pub requires_external_ids: Vec<String>, + /// Whether the plugin announces chapter-level releases. + #[serde(default)] + pub can_announce_chapters: bool, + /// Whether the plugin announces volume-level releases. + #[serde(default)] + pub can_announce_volumes: bool, +} + +impl Default for ReleaseSourceCapability { + fn default() -> Self { + Self { + kinds: Vec::new(), + requires_aliases: false, + requires_external_ids: Vec::new(), + can_announce_chapters: true, + can_announce_volumes: true, + } + } +} + +/// Kind of release source. Mirrors the `release_sources.kind` column on the +/// host side, but lives here so plugins can declare it without depending on +/// the database schema. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum ReleaseSourceKind { + /// Per-uploader feed (e.g., a Nyaa user RSS feed). + RssUploader, + /// Per-series feed (e.g., MangaUpdates RSS for a single series). + RssSeries, + /// Generic API-driven feed. + ApiFeed, + /// Metadata-derived signal (informational; usually doesn't write the + /// ledger). + MetadataFeed, +} + +impl ReleaseSourceKind { + /// Canonical kebab-case string matching `release_sources.kind` and the + /// serde representation. Used when comparing against string-typed + /// `kind` fields parsed from RPC requests. + pub fn as_str(&self) -> &'static str { + match self { + Self::RssUploader => "rss-uploader", + Self::RssSeries => "rss-series", + Self::ApiFeed => "api-feed", + Self::MetadataFeed => "metadata-feed", + } + } +} + +impl PluginCapabilities { + /// Check if the plugin can provide series metadata + pub fn can_provide_series_metadata(&self) -> bool { + self.metadata_provider + .contains(&MetadataContentType::Series) + } + + /// Check if the plugin can provide book metadata + pub fn can_provide_book_metadata(&self) -> bool { + self.metadata_provider.contains(&MetadataContentType::Book) + } + + /// Whether this plugin declares the `release_source` capability. + pub fn is_release_source(&self) -> bool { + self.release_source.is_some() + } + + /// Infer the plugin type from capabilities. + /// + /// User-facing capabilities (`user_read_sync`, `user_recommendation_provider`) + /// indicate a "user" plugin. Metadata-provider and release-source + /// capabilities indicate a "system" plugin. Returns `None` when + /// capabilities are empty. + pub fn inferred_plugin_type(&self) -> Option<PluginManifestType> { + if self.user_read_sync || self.user_recommendation_provider { + Some(PluginManifestType::User) + } else if !self.metadata_provider.is_empty() || self.release_source.is_some() { + Some(PluginManifestType::System) + } else { + None + } + } +} + +/// Plugin manifest type (declared by the plugin in its manifest) +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum PluginManifestType { + /// System plugin: admin-configured, operates on shared library metadata + #[default] + System, + /// User plugin: per-user integrations (sync, recommendations) + User, +} + +impl std::fmt::Display for PluginManifestType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::System => write!(f, "system"), + Self::User => write!(f, "user"), + } + } +} + +/// OAuth 2.0 configuration for user plugins +/// +/// Plugins declare their OAuth requirements in the manifest. Codex handles +/// the OAuth flow (authorization URL generation, code exchange, token storage) +/// so plugins never directly interact with the OAuth provider. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct OAuthConfig { + /// OAuth 2.0 authorization endpoint URL + pub authorization_url: String, + /// OAuth 2.0 token endpoint URL + pub token_url: String, + /// Required OAuth scopes + #[serde(default)] + pub scopes: Vec<String>, + /// Whether to use PKCE (Proof Key for Code Exchange) + /// Recommended for public clients; defaults to true + #[serde(default = "default_true")] + pub pkce: bool, + /// Optional user info endpoint URL (to fetch external identity after auth) + #[serde(default, skip_serializing_if = "Option::is_none")] + pub user_info_url: Option<String>, + /// OAuth client ID (can be overridden by admin in plugin config) + #[serde(default, skip_serializing_if = "Option::is_none")] + pub client_id: Option<String>, +} + +fn default_true() -> bool { + true +} + +impl OAuthConfig { + /// Validate that the OAuth config has all required fields + #[allow(dead_code)] // Protocol contract: validation for plugin registration + pub fn validate(&self) -> Result<(), String> { + if self.authorization_url.is_empty() { + return Err("OAuth authorization_url is required".to_string()); + } + if self.token_url.is_empty() { + return Err("OAuth token_url is required".to_string()); + } + // Validate URLs start with https:// (or http:// for local dev) + if !self.authorization_url.starts_with("https://") + && !self.authorization_url.starts_with("http://") + { + return Err(format!( + "Invalid OAuth authorization_url (must start with http:// or https://): {}", + self.authorization_url + )); + } + if !self.token_url.starts_with("https://") && !self.token_url.starts_with("http://") { + return Err(format!( + "Invalid OAuth token_url (must start with http:// or https://): {}", + self.token_url + )); + } + if let Some(ref user_info_url) = self.user_info_url + && !user_info_url.starts_with("https://") + && !user_info_url.starts_with("http://") + { + return Err(format!( + "Invalid OAuth user_info_url (must start with http:// or https://): {}", + user_info_url + )); + } + Ok(()) + } +} + +/// Credential field definition +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CredentialField { + /// Credential key (e.g., "api_key") + pub key: String, + /// Display label (e.g., "API Key") + pub label: String, + /// Description for the user + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option<String>, + /// Whether this credential is required + #[serde(default)] + pub required: bool, + /// Whether to mask the value in UI + #[serde(default)] + pub sensitive: bool, + /// Input type for UI + #[serde(default)] + pub credential_type: CredentialType, +} + +/// Credential input type +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum CredentialType { + #[default] + String, + Password, + OAuth, +} + +// ============================================================================= +// Plugin Scopes (Server-Side) +// ============================================================================= + +/// Plugin scope defining where it can be invoked (server-side only). +/// +/// Note: Scopes are determined by the server based on plugin capabilities, +/// not declared in the plugin manifest. This enum is used internally by Codex +/// to control where plugins can be invoked. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PluginScope { + // ========================================================================= + // Series Scopes + // ========================================================================= + /// Series detail page dropdown (search + auto-match) + #[serde(rename = "series:detail")] + SeriesDetail, + /// Series list bulk actions (auto-match only) + #[serde(rename = "series:bulk")] + SeriesBulk, + + // ========================================================================= + // Book Scopes + // ========================================================================= + /// Book detail page dropdown (search + auto-match) + #[serde(rename = "book:detail")] + BookDetail, + /// Book list bulk actions (auto-match only) + #[serde(rename = "book:bulk")] + BookBulk, + + // ========================================================================= + // Library Scopes + // ========================================================================= + /// Library dropdown action (auto-match all series/books) + #[serde(rename = "library:detail")] + LibraryDetail, + /// Post-analysis hook (auto-match if forced/changed) + #[serde(rename = "library:scan")] + LibraryScan, +} + +impl PluginScope { + /// Get scopes available for series metadata providers + pub fn series_scopes() -> Vec<Self> { + vec![ + Self::SeriesDetail, + Self::SeriesBulk, + Self::LibraryDetail, + Self::LibraryScan, + ] + } + + /// Get scopes available for book metadata providers + #[allow(dead_code)] // Protocol contract: scope helpers for book metadata plugins + pub fn book_scopes() -> Vec<Self> { + vec![ + Self::BookDetail, + Self::BookBulk, + Self::LibraryDetail, + Self::LibraryScan, + ] + } + + /// Get all scopes (series + book + library) + #[allow(dead_code)] // Protocol contract: scope helpers for multi-content plugins + pub fn all_scopes() -> Vec<Self> { + vec![ + Self::SeriesDetail, + Self::SeriesBulk, + Self::BookDetail, + Self::BookBulk, + Self::LibraryDetail, + Self::LibraryScan, + ] + } +} diff --git a/src/models/preprocessing.rs b/src/models/preprocessing.rs new file mode 100644 index 00000000..3181a8bf --- /dev/null +++ b/src/models/preprocessing.rs @@ -0,0 +1,562 @@ +//! Types for preprocessing rules and auto-match conditions. +//! +//! This module defines the data structures used for: +//! - Title preprocessing rules (regex-based transformations) +//! - Auto-match conditions (conditional logic for plugin matching) +//! - Condition operators (comparison operations) + +#![allow(dead_code)] +//! +//! ## Example: Preprocessing Rules +//! +//! ```json +//! [ +//! { +//! "pattern": "\\s*\\(Digital\\)$", +//! "replacement": "", +//! "description": "Remove (Digital) suffix", +//! "enabled": true +//! } +//! ] +//! ``` +//! +//! ## Example: Auto-Match Conditions +//! +//! ```json +//! { +//! "mode": "all", +//! "rules": [ +//! { +//! "field": "external_ids.plugin:mangabaka", +//! "operator": "is_null" +//! }, +//! { +//! "field": "book_count", +//! "operator": "gte", +//! "value": 1 +//! } +//! ] +//! } +//! ``` + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +// ============================================================================= +// Preprocessing Rules +// ============================================================================= + +/// A single preprocessing rule that transforms text using regex. +/// +/// Rules are applied in order during scan time to clean up series titles +/// before they are used for metadata searches. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct PreprocessingRule { + /// Regex pattern to match (uses Rust regex syntax) + pub pattern: String, + + /// Replacement string (supports $1, $2, etc. for capture groups) + pub replacement: String, + + /// Human-readable description of what this rule does + #[serde(default)] + pub description: Option<String>, + + /// Whether this rule is active (default: true) + #[serde(default = "default_enabled")] + pub enabled: bool, +} + +fn default_enabled() -> bool { + true +} + +impl PreprocessingRule { + /// Create a new preprocessing rule. + pub fn new(pattern: impl Into<String>, replacement: impl Into<String>) -> Self { + Self { + pattern: pattern.into(), + replacement: replacement.into(), + description: None, + enabled: true, + } + } + + /// Create a new preprocessing rule with a description. + pub fn with_description( + pattern: impl Into<String>, + replacement: impl Into<String>, + description: impl Into<String>, + ) -> Self { + Self { + pattern: pattern.into(), + replacement: replacement.into(), + description: Some(description.into()), + enabled: true, + } + } +} + +// ============================================================================= +// Auto-Match Conditions +// ============================================================================= + +/// Auto-match conditions that control when plugin matching should occur. +/// +/// Conditions can be configured at both library and plugin levels: +/// - Library conditions are checked first (if any fail, skip auto-match for this library) +/// - Plugin conditions are checked second (if any fail, skip this plugin) +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct AutoMatchConditions { + /// How to combine the rules: "all" (AND) or "any" (OR) + #[serde(default)] + pub mode: ConditionMode, + + /// List of condition rules to evaluate + #[serde(default)] + pub rules: Vec<ConditionRule>, +} + +impl Default for AutoMatchConditions { + fn default() -> Self { + Self { + mode: ConditionMode::All, + rules: Vec::new(), + } + } +} + +impl AutoMatchConditions { + /// Create new conditions with the given mode. + pub fn new(mode: ConditionMode) -> Self { + Self { + mode, + rules: Vec::new(), + } + } + + /// Add a rule to the conditions. + pub fn with_rule(mut self, rule: ConditionRule) -> Self { + self.rules.push(rule); + self + } + + /// Check if there are any rules to evaluate. + pub fn is_empty(&self) -> bool { + self.rules.is_empty() + } +} + +/// How to combine multiple condition rules. +#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum ConditionMode { + /// All rules must pass (logical AND) + #[default] + All, + /// Any rule must pass (logical OR) + Any, +} + +/// A single condition rule that evaluates a field against an operator. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct ConditionRule { + /// Field path to evaluate (e.g., "book_count", "metadata.title", "external_ids.plugin:mangabaka") + pub field: String, + + /// Comparison operator + pub operator: ConditionOperator, + + /// Value to compare against (not required for is_null/is_not_null) + #[serde(default, skip_serializing_if = "Option::is_none")] + pub value: Option<Value>, +} + +impl ConditionRule { + /// Create a new condition rule. + pub fn new(field: impl Into<String>, operator: ConditionOperator) -> Self { + Self { + field: field.into(), + operator, + value: None, + } + } + + /// Create a new condition rule with a value. + pub fn with_value(field: impl Into<String>, operator: ConditionOperator, value: Value) -> Self { + Self { + field: field.into(), + operator, + value: Some(value), + } + } +} + +/// Comparison operators for condition evaluation. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum ConditionOperator { + /// Field is null, empty, or missing + IsNull, + /// Field has a non-null, non-empty value + IsNotNull, + /// Exact match (string or number) + Equals, + /// Not equal + NotEquals, + /// Greater than (numeric) + Gt, + /// Greater than or equal (numeric) + Gte, + /// Less than (numeric) + Lt, + /// Less than or equal (numeric) + Lte, + /// String contains substring + Contains, + /// String does not contain substring + NotContains, + /// String starts with prefix + StartsWith, + /// String ends with suffix + EndsWith, + /// String matches regex pattern + Matches, + /// Value is in the provided array + In, + /// Value is not in the provided array + NotIn, +} + +impl ConditionOperator { + /// Check if this operator requires a value. + pub fn requires_value(&self) -> bool { + !matches!( + self, + ConditionOperator::IsNull | ConditionOperator::IsNotNull + ) + } + + /// Check if this operator is for numeric comparison. + pub fn is_numeric(&self) -> bool { + matches!( + self, + ConditionOperator::Gt + | ConditionOperator::Gte + | ConditionOperator::Lt + | ConditionOperator::Lte + ) + } + + /// Check if this operator is for string comparison. + pub fn is_string(&self) -> bool { + matches!( + self, + ConditionOperator::Contains + | ConditionOperator::NotContains + | ConditionOperator::StartsWith + | ConditionOperator::EndsWith + | ConditionOperator::Matches + ) + } +} + +// ============================================================================= +// Parsing Helpers +// ============================================================================= + +/// Parse preprocessing rules from JSON string. +pub fn parse_preprocessing_rules(json: Option<&str>) -> Result<Vec<PreprocessingRule>, String> { + match json { + None => Ok(Vec::new()), + Some(s) if s.trim().is_empty() => Ok(Vec::new()), + Some(s) => serde_json::from_str(s) + .map_err(|e| format!("Failed to parse preprocessing rules: {}", e)), + } +} + +/// Parse auto-match conditions from JSON string. +pub fn parse_auto_match_conditions( + json: Option<&str>, +) -> Result<Option<AutoMatchConditions>, String> { + match json { + None => Ok(None), + Some(s) if s.trim().is_empty() => Ok(None), + Some(s) => serde_json::from_str(s) + .map(Some) + .map_err(|e| format!("Failed to parse auto-match conditions: {}", e)), + } +} + +// ============================================================================= +// Tests +// ============================================================================= + +#[cfg(test)] +mod tests { + use super::*; + + // ========================================================================= + // PreprocessingRule Tests + // ========================================================================= + + #[test] + fn test_preprocessing_rule_new() { + let rule = PreprocessingRule::new(r"\s*\(Digital\)$", ""); + assert_eq!(rule.pattern, r"\s*\(Digital\)$"); + assert_eq!(rule.replacement, ""); + assert!(rule.description.is_none()); + assert!(rule.enabled); + } + + #[test] + fn test_preprocessing_rule_with_description() { + let rule = + PreprocessingRule::with_description(r"\s*\(Digital\)$", "", "Remove (Digital) suffix"); + assert_eq!( + rule.description, + Some("Remove (Digital) suffix".to_string()) + ); + } + + #[test] + fn test_preprocessing_rule_serialization() { + let rule = PreprocessingRule { + pattern: r"\s*\(Digital\)$".to_string(), + replacement: "".to_string(), + description: Some("Remove (Digital) suffix".to_string()), + enabled: true, + }; + + let json = serde_json::to_string(&rule).unwrap(); + let parsed: PreprocessingRule = serde_json::from_str(&json).unwrap(); + assert_eq!(rule, parsed); + } + + #[test] + fn test_preprocessing_rule_default_enabled() { + // When enabled is not specified, it should default to true + let json = r#"{"pattern": "test", "replacement": ""}"#; + let rule: PreprocessingRule = serde_json::from_str(json).unwrap(); + assert!(rule.enabled); + } + + #[test] + fn test_preprocessing_rules_array() { + let json = r#"[ + {"pattern": "\\s*\\(Digital\\)$", "replacement": ""}, + {"pattern": "\\s+v\\d+$", "replacement": "", "description": "Remove version suffix", "enabled": false} + ]"#; + let rules: Vec<PreprocessingRule> = serde_json::from_str(json).unwrap(); + assert_eq!(rules.len(), 2); + assert!(rules[0].enabled); + assert!(!rules[1].enabled); + } + + // ========================================================================= + // AutoMatchConditions Tests + // ========================================================================= + + #[test] + fn test_auto_match_conditions_default() { + let conditions = AutoMatchConditions::default(); + assert_eq!(conditions.mode, ConditionMode::All); + assert!(conditions.rules.is_empty()); + assert!(conditions.is_empty()); + } + + #[test] + fn test_auto_match_conditions_builder() { + let conditions = AutoMatchConditions::new(ConditionMode::Any) + .with_rule(ConditionRule::new("book_count", ConditionOperator::Gte)) + .with_rule(ConditionRule::new( + "external_ids.count", + ConditionOperator::IsNull, + )); + + assert_eq!(conditions.mode, ConditionMode::Any); + assert_eq!(conditions.rules.len(), 2); + assert!(!conditions.is_empty()); + } + + #[test] + fn test_auto_match_conditions_serialization() { + let conditions = AutoMatchConditions { + mode: ConditionMode::All, + rules: vec![ + ConditionRule { + field: "external_ids.plugin:mangabaka".to_string(), + operator: ConditionOperator::IsNull, + value: None, + }, + ConditionRule { + field: "book_count".to_string(), + operator: ConditionOperator::Gte, + value: Some(serde_json::json!(1)), + }, + ], + }; + + let json = serde_json::to_string_pretty(&conditions).unwrap(); + let parsed: AutoMatchConditions = serde_json::from_str(&json).unwrap(); + assert_eq!(conditions, parsed); + } + + #[test] + fn test_auto_match_conditions_from_json() { + let json = r#"{ + "mode": "all", + "rules": [ + {"field": "external_ids.plugin:mangabaka", "operator": "is_null"}, + {"field": "book_count", "operator": "gte", "value": 1} + ] + }"#; + let conditions: AutoMatchConditions = serde_json::from_str(json).unwrap(); + assert_eq!(conditions.mode, ConditionMode::All); + assert_eq!(conditions.rules.len(), 2); + assert_eq!(conditions.rules[0].operator, ConditionOperator::IsNull); + assert_eq!(conditions.rules[1].operator, ConditionOperator::Gte); + } + + // ========================================================================= + // ConditionRule Tests + // ========================================================================= + + #[test] + fn test_condition_rule_new() { + let rule = ConditionRule::new("book_count", ConditionOperator::IsNull); + assert_eq!(rule.field, "book_count"); + assert_eq!(rule.operator, ConditionOperator::IsNull); + assert!(rule.value.is_none()); + } + + #[test] + fn test_condition_rule_with_value() { + let rule = + ConditionRule::with_value("book_count", ConditionOperator::Gte, serde_json::json!(5)); + assert_eq!(rule.field, "book_count"); + assert_eq!(rule.operator, ConditionOperator::Gte); + assert_eq!(rule.value, Some(serde_json::json!(5))); + } + + // ========================================================================= + // ConditionOperator Tests + // ========================================================================= + + #[test] + fn test_condition_operator_requires_value() { + assert!(!ConditionOperator::IsNull.requires_value()); + assert!(!ConditionOperator::IsNotNull.requires_value()); + assert!(ConditionOperator::Equals.requires_value()); + assert!(ConditionOperator::Gte.requires_value()); + assert!(ConditionOperator::Contains.requires_value()); + } + + #[test] + fn test_condition_operator_is_numeric() { + assert!(ConditionOperator::Gt.is_numeric()); + assert!(ConditionOperator::Gte.is_numeric()); + assert!(ConditionOperator::Lt.is_numeric()); + assert!(ConditionOperator::Lte.is_numeric()); + assert!(!ConditionOperator::Equals.is_numeric()); + assert!(!ConditionOperator::Contains.is_numeric()); + } + + #[test] + fn test_condition_operator_is_string() { + assert!(ConditionOperator::Contains.is_string()); + assert!(ConditionOperator::NotContains.is_string()); + assert!(ConditionOperator::StartsWith.is_string()); + assert!(ConditionOperator::EndsWith.is_string()); + assert!(ConditionOperator::Matches.is_string()); + assert!(!ConditionOperator::Equals.is_string()); + assert!(!ConditionOperator::Gt.is_string()); + } + + #[test] + fn test_condition_operator_serialization() { + let json = serde_json::to_string(&ConditionOperator::IsNull).unwrap(); + assert_eq!(json, "\"is_null\""); + + let json = serde_json::to_string(&ConditionOperator::Gte).unwrap(); + assert_eq!(json, "\"gte\""); + + let parsed: ConditionOperator = serde_json::from_str("\"not_contains\"").unwrap(); + assert_eq!(parsed, ConditionOperator::NotContains); + } + + // ========================================================================= + // Parsing Helper Tests + // ========================================================================= + + #[test] + fn test_parse_preprocessing_rules_none() { + let result = parse_preprocessing_rules(None); + assert!(result.is_ok()); + assert!(result.unwrap().is_empty()); + } + + #[test] + fn test_parse_preprocessing_rules_empty() { + let result = parse_preprocessing_rules(Some("")); + assert!(result.is_ok()); + assert!(result.unwrap().is_empty()); + + let result = parse_preprocessing_rules(Some(" ")); + assert!(result.is_ok()); + assert!(result.unwrap().is_empty()); + } + + #[test] + fn test_parse_preprocessing_rules_valid() { + let json = r#"[{"pattern": "test", "replacement": ""}]"#; + let result = parse_preprocessing_rules(Some(json)); + assert!(result.is_ok()); + assert_eq!(result.unwrap().len(), 1); + } + + #[test] + fn test_parse_preprocessing_rules_invalid() { + let result = parse_preprocessing_rules(Some("not json")); + assert!(result.is_err()); + assert!( + result + .unwrap_err() + .contains("Failed to parse preprocessing rules") + ); + } + + #[test] + fn test_parse_auto_match_conditions_none() { + let result = parse_auto_match_conditions(None); + assert!(result.is_ok()); + assert!(result.unwrap().is_none()); + } + + #[test] + fn test_parse_auto_match_conditions_empty() { + let result = parse_auto_match_conditions(Some("")); + assert!(result.is_ok()); + assert!(result.unwrap().is_none()); + } + + #[test] + fn test_parse_auto_match_conditions_valid() { + let json = r#"{"mode": "all", "rules": []}"#; + let result = parse_auto_match_conditions(Some(json)); + assert!(result.is_ok()); + let conditions = result.unwrap().unwrap(); + assert_eq!(conditions.mode, ConditionMode::All); + } + + #[test] + fn test_parse_auto_match_conditions_invalid() { + let result = parse_auto_match_conditions(Some("not json")); + assert!(result.is_err()); + assert!( + result + .unwrap_err() + .contains("Failed to parse auto-match conditions") + ); + } +} diff --git a/src/models/release.rs b/src/models/release.rs new file mode 100644 index 00000000..02315569 --- /dev/null +++ b/src/models/release.rs @@ -0,0 +1,107 @@ +//! Release-tracking value types shared across the db, services, and tasks +//! layers. +//! +//! These are pure data shapes and small helpers. The ledger-shaped service +//! logic (auto-ignore, candidate validation, language gating) stays in +//! [`crate::services::release`]; this module only holds the types and the +//! span helpers that repositories need to speak. + +use serde::{Deserialize, Serialize}; + +/// Inclusive numeric span. Single values are encoded as `start == end` +/// (e.g. `NumericSpan { start: 5.0, end: 5.0 }`). +/// +/// A release candidate carries one [`Vec<NumericSpan>`] per axis (volumes +/// and chapters). Disjoint coverage (`v01-04 + v06-09`) is preserved as +/// multiple spans; the host's auto-ignore walks every value in every span +/// before deciding the user owns the release. +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct NumericSpan { + pub start: f64, + pub end: f64, +} + +/// Normalize a span list: +/// 1. Swap any span where `start > end` (defensive against buggy plugins). +/// 2. Sort ascending by `start`, then `end`. +/// 3. Merge overlapping spans (touching counts as overlap). +/// +/// Mirrors the parser-side `normalizeSpans` in `plugins/release-nyaa` so +/// host and plugin agree on the canonical shape stored in the ledger. +/// Returns `None` when the input is `Some(empty)` so callers can collapse +/// "I parsed an empty list" into "no info" before persistence. +pub fn normalize_spans(spans: Option<Vec<NumericSpan>>) -> Option<Vec<NumericSpan>> { + let raw = spans?; + if raw.is_empty() { + return None; + } + let mut fixed: Vec<NumericSpan> = raw + .into_iter() + .map(|s| { + if s.start <= s.end { + s + } else { + NumericSpan { + start: s.end, + end: s.start, + } + } + }) + .collect(); + fixed.sort_by(|a, b| { + a.start + .partial_cmp(&b.start) + .unwrap_or(std::cmp::Ordering::Equal) + .then_with(|| { + a.end + .partial_cmp(&b.end) + .unwrap_or(std::cmp::Ordering::Equal) + }) + }); + let mut out: Vec<NumericSpan> = Vec::with_capacity(fixed.len()); + for s in fixed { + match out.last_mut() { + Some(last) if s.start <= last.end => { + if s.end > last.end { + last.end = s.end; + } + } + _ => out.push(s), + } + } + Some(out) +} + +/// Highest end-value across every span. `None` for an empty / missing list. +/// Used to derive the primary scalar (`chapter` / `volume`) the SQL ORDER BY +/// clauses still rely on. +pub fn primary_value(spans: Option<&Vec<NumericSpan>>) -> Option<f64> { + let list = spans?; + list.iter().map(|s| s.end).fold(None, |acc, v| match acc { + None => Some(v), + Some(cur) if v > cur => Some(v), + other => other, + }) +} + +/// Per-series ownership signature consumed by the auto-ignore logic in +/// [`crate::services::release::auto_ignore`]. Produced by +/// [`crate::db::repositories::SeriesRepository::get_owned_release_keys_for_series`]. +#[derive(Debug, Default, Clone)] +pub struct OwnedReleaseKeys { + /// `(volume, chapter)` pairs from book metadata, after filtering out + /// rows with both fields null. + /// + /// - `(Some(v), None)` — whole volume `v` owned (no specific chapter). + /// - `(Some(v), Some(c))` — chapter `c` of volume `v` owned. + /// - `(None, Some(c))` — chapter `c` owned, volume unknown. + pub keys: Vec<(Option<i32>, Option<f64>)>, + /// `true` if at least one book in the series carries volume metadata. + /// When `false`, callers fall back to [`Self::volumes_owned_count`]. + pub has_any_volume_metadata: bool, + /// Count of "complete-volume" books (volume IS NOT NULL AND chapter + /// IS NULL). Only consulted in the count-fallback branch when + /// [`Self::has_any_volume_metadata`] is `false`. + pub volumes_owned_count: i64, +} diff --git a/src/models/sort.rs b/src/models/sort.rs new file mode 100644 index 00000000..012f706a --- /dev/null +++ b/src/models/sort.rs @@ -0,0 +1,293 @@ +//! Sort parameters for list queries. +//! +//! Lives in `models` so db repositories can take typed sort parameters +//! without depending on the api layer where the public DTO names also live. + +use std::fmt; +use std::str::FromStr; + +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; + +/// Sort direction for list queries +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "lowercase")] +pub enum SortDirection { + #[default] + Asc, + Desc, +} + +impl fmt::Display for SortDirection { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + SortDirection::Asc => write!(f, "asc"), + SortDirection::Desc => write!(f, "desc"), + } + } +} + +impl FromStr for SortDirection { + type Err = String; + + fn from_str(s: &str) -> Result<Self, Self::Err> { + match s.to_lowercase().as_str() { + "asc" => Ok(SortDirection::Asc), + "desc" => Ok(SortDirection::Desc), + _ => Err(format!("Invalid sort direction: {}", s)), + } + } +} + +/// Sort field options for series list queries +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum SeriesSortField { + /// Sort by series name (uses title_sort if available, otherwise title) + #[default] + Name, + /// Sort by date added to library + DateAdded, + /// Sort by last update time + DateUpdated, + /// Sort by release year + ReleaseDate, + /// Sort by last read time (user-specific) + DateRead, + /// Sort by number of books in the series + BookCount, + /// Sort by user rating (user-specific) + Rating, + /// Sort by community average rating + CommunityRating, + /// Sort by external rating (highest external source rating) + ExternalRating, + /// Sort by fuzzy-search relevance score. Only meaningful when a + /// `fullTextSearch` query is present and `search.fuzzy.enabled` is on; + /// otherwise handlers fall back to the natural default (`Name`). + Relevance, +} + +impl fmt::Display for SeriesSortField { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + SeriesSortField::Name => write!(f, "name"), + SeriesSortField::DateAdded => write!(f, "date_added"), + SeriesSortField::DateUpdated => write!(f, "date_updated"), + SeriesSortField::ReleaseDate => write!(f, "release_date"), + SeriesSortField::DateRead => write!(f, "date_read"), + SeriesSortField::BookCount => write!(f, "book_count"), + SeriesSortField::Rating => write!(f, "rating"), + SeriesSortField::CommunityRating => write!(f, "community_rating"), + SeriesSortField::ExternalRating => write!(f, "external_rating"), + SeriesSortField::Relevance => write!(f, "relevance"), + } + } +} + +impl FromStr for SeriesSortField { + type Err = String; + + fn from_str(s: &str) -> Result<Self, Self::Err> { + match s.to_lowercase().as_str() { + "name" => Ok(SeriesSortField::Name), + "date_added" | "created_at" => Ok(SeriesSortField::DateAdded), + "date_updated" | "updated_at" => Ok(SeriesSortField::DateUpdated), + "release_date" | "year" => Ok(SeriesSortField::ReleaseDate), + "date_read" => Ok(SeriesSortField::DateRead), + "book_count" => Ok(SeriesSortField::BookCount), + "rating" | "user_rating" => Ok(SeriesSortField::Rating), + "community_rating" | "avg_rating" => Ok(SeriesSortField::CommunityRating), + "external_rating" => Ok(SeriesSortField::ExternalRating), + "relevance" | "score" => Ok(SeriesSortField::Relevance), + _ => Err(format!("Invalid sort field: {}", s)), + } + } +} + +/// Parsed sort parameter for series queries +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct SeriesSortParam { + pub field: SeriesSortField, + pub direction: SortDirection, +} + +impl Default for SeriesSortParam { + fn default() -> Self { + Self { + field: SeriesSortField::Name, + direction: SortDirection::Asc, + } + } +} + +#[allow(dead_code)] // Public API for series sorting - used in query parsing +impl SeriesSortParam { + pub fn new(field: SeriesSortField, direction: SortDirection) -> Self { + Self { field, direction } + } + + /// Parse from "field,direction" format (e.g., "name,asc"). + /// + /// "relevance" (with or without a direction) is accepted as a shorthand + /// that pairs with a `fullTextSearch` query. + pub fn parse(s: &str) -> Self { + let trimmed = s.trim(); + if trimmed.eq_ignore_ascii_case("relevance") || trimmed.eq_ignore_ascii_case("score") { + return Self { + field: SeriesSortField::Relevance, + direction: SortDirection::Desc, + }; + } + + let parts: Vec<&str> = trimmed.split(',').collect(); + if parts.len() != 2 { + return Self::default(); + } + + let field = SeriesSortField::from_str(parts[0]).unwrap_or_default(); + let direction = SortDirection::from_str(parts[1]).unwrap_or_default(); + + Self { field, direction } + } + + /// Check if this sort requires user-specific data (e.g., read progress) + pub fn requires_user_context(&self) -> bool { + matches!( + self.field, + SeriesSortField::DateRead | SeriesSortField::Rating + ) + } + + /// Check if this sort requires aggregation + pub fn requires_aggregation(&self) -> bool { + matches!( + self.field, + SeriesSortField::BookCount + | SeriesSortField::Rating + | SeriesSortField::CommunityRating + | SeriesSortField::ExternalRating + ) + } +} + +impl fmt::Display for SeriesSortParam { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{},{}", self.field, self.direction) + } +} + +/// Sort field options for book list queries +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum BookSortField { + /// Compound sort: series name alphabetically, then books by number within series + /// This is the "reading order" sort + Series, + /// Sort by book title + #[default] + Title, + /// Sort by date added to library + DateAdded, + /// Sort by release date + ReleaseDate, + /// Sort by chapter/book number + ChapterNumber, + /// Sort by file size + FileSize, + /// Sort by filename + Filename, + /// Sort by page count + PageCount, + /// Sort by last read date (requires user_id for filtering) + LastRead, + /// Sort by fuzzy-search relevance score. Only meaningful when a + /// `fullTextSearch` query is present and `search.fuzzy.enabled` is on; + /// otherwise handlers fall back to the natural default (`Title`). + Relevance, +} + +impl fmt::Display for BookSortField { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + BookSortField::Series => write!(f, "series"), + BookSortField::Title => write!(f, "title"), + BookSortField::DateAdded => write!(f, "created_at"), + BookSortField::ReleaseDate => write!(f, "release_date"), + BookSortField::ChapterNumber => write!(f, "chapter_number"), + BookSortField::FileSize => write!(f, "file_size"), + BookSortField::Filename => write!(f, "filename"), + BookSortField::PageCount => write!(f, "page_count"), + BookSortField::LastRead => write!(f, "last_read"), + BookSortField::Relevance => write!(f, "relevance"), + } + } +} + +impl FromStr for BookSortField { + type Err = String; + + fn from_str(s: &str) -> Result<Self, Self::Err> { + match s.to_lowercase().as_str() { + "series" => Ok(BookSortField::Series), + "title" => Ok(BookSortField::Title), + "created_at" | "date_added" => Ok(BookSortField::DateAdded), + "release_date" => Ok(BookSortField::ReleaseDate), + "chapter_number" | "number" => Ok(BookSortField::ChapterNumber), + "file_size" => Ok(BookSortField::FileSize), + "filename" => Ok(BookSortField::Filename), + "page_count" => Ok(BookSortField::PageCount), + "last_read" | "read_date" => Ok(BookSortField::LastRead), + "relevance" | "score" => Ok(BookSortField::Relevance), + _ => Err(format!("Invalid sort field: {}", s)), + } + } +} + +/// Parsed sort parameter for book queries +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct BookSortParam { + pub field: BookSortField, + pub direction: SortDirection, +} + +impl Default for BookSortParam { + fn default() -> Self { + Self { + field: BookSortField::Title, + direction: SortDirection::Asc, + } + } +} + +impl BookSortParam { + /// Parse from "field,direction" format (e.g., "title,asc"). + /// + /// "relevance" (with or without a direction) is accepted as a shorthand + /// that pairs with a `fullTextSearch` query. + pub fn parse(s: &str) -> Self { + let trimmed = s.trim(); + if trimmed.eq_ignore_ascii_case("relevance") || trimmed.eq_ignore_ascii_case("score") { + return Self { + field: BookSortField::Relevance, + direction: SortDirection::Desc, + }; + } + + let parts: Vec<&str> = trimmed.split(',').collect(); + if parts.len() != 2 { + return Self::default(); + } + + let field = BookSortField::from_str(parts[0]).unwrap_or_default(); + let direction = SortDirection::from_str(parts[1]).unwrap_or_default(); + + Self { field, direction } + } +} + +impl fmt::Display for BookSortParam { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{},{}", self.field, self.direction) + } +} diff --git a/src/models.rs b/src/models/strategies.rs similarity index 98% rename from src/models.rs rename to src/models/strategies.rs index f65b6f95..56e5a9ac 100644 --- a/src/models.rs +++ b/src/models/strategies.rs @@ -9,7 +9,12 @@ use std::fmt; use std::str::FromStr; use utoipa::ToSchema; -use crate::utils::default_true; +/// Local copy of the `default_true` serde helper. The original lives in +/// `crate::utils::serde`, but `models` sits below `utils` in the layering so +/// the inlined version keeps `models` dependency-free within the crate. +fn default_true() -> bool { + true +} // ============================================================================ // Series Scanning Strategy diff --git a/src/models/task.rs b/src/models/task.rs new file mode 100644 index 00000000..d4389b5c --- /dev/null +++ b/src/models/task.rs @@ -0,0 +1,1493 @@ +//! Task types supported by the distributed task queue +//! +//! TODO: Remove allow(dead_code) once all task features are fully integrated + +#![allow(dead_code)] + +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; +use uuid::Uuid; + +/// Default retry delay in seconds for rate-limited tasks +pub const DEFAULT_RATE_LIMIT_RETRY_SECONDS: u64 = 30; + +/// Default maximum number of rate limit reschedules before marking as failed +pub const DEFAULT_MAX_RESCHEDULES: i32 = 10; + +/// Task types supported by the distributed task queue +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum TaskType { + /// Scan a library for new/changed books + ScanLibrary { + #[serde(rename = "libraryId")] + library_id: Uuid, + #[serde(default = "default_mode")] + mode: String, // "normal" or "deep" + }, + + /// Analyze a single book's metadata + AnalyzeBook { + #[serde(rename = "bookId")] + book_id: Uuid, + #[serde(default)] + force: bool, + }, + + /// Analyze all books in a series (always forces re-analysis) + AnalyzeSeries { + #[serde(rename = "seriesId")] + series_id: Uuid, + }, + + /// Purge soft-deleted books from a library + PurgeDeleted { + #[serde(rename = "libraryId")] + library_id: Uuid, + }, + + /// Refresh metadata from external source + RefreshMetadata { + #[serde(rename = "bookId")] + book_id: Uuid, + source: String, // "comicvine", "openlibrary", etc. + }, + + /// Scheduled per-job metadata refresh. + /// + /// Loads the [`library_jobs`] row by `job_id`, decodes its config (single + /// provider + field groups + safety options), walks the library's series, + /// and refreshes metadata via the existing `MetadataApplier`. + RefreshLibraryMetadata { + #[serde(rename = "jobId")] + job_id: Uuid, + }, + + /// Generate thumbnails for books in a scope (library, series, specific books, or all) + /// This is a fan-out task that enqueues individual GenerateThumbnail tasks + GenerateThumbnails { + #[serde(rename = "libraryId")] + library_id: Option<Uuid>, // If set, only books in this library + #[serde(rename = "seriesId")] + series_id: Option<Uuid>, // If set, only books in this series (takes precedence over library_id) + #[serde(rename = "seriesIds", default)] + series_ids: Option<Vec<Uuid>>, // If set, only books in these specific series (takes precedence over series_id and library_id) + #[serde(rename = "bookIds", default)] + book_ids: Option<Vec<Uuid>>, // If set, only these specific books (takes precedence over all other scopes) + #[serde(default)] + force: bool, // If true, regenerate all thumbnails; if false, only missing ones + }, + + /// Generate thumbnail for a single book + GenerateThumbnail { + #[serde(rename = "bookId")] + book_id: Uuid, + #[serde(default)] + force: bool, // If true, regenerate even if thumbnail exists + }, + + /// Generate thumbnail for a series (from first book's cover) + GenerateSeriesThumbnail { + #[serde(rename = "seriesId")] + series_id: Uuid, + #[serde(default)] + force: bool, // If true, regenerate even if thumbnail exists + }, + + /// Generate thumbnails for series in a scope (library, specific series, or all) + /// This is a fan-out task that enqueues individual GenerateSeriesThumbnail tasks + GenerateSeriesThumbnails { + #[serde(rename = "libraryId")] + library_id: Option<Uuid>, // If set, only series in this library + #[serde(rename = "seriesIds", default)] + series_ids: Option<Vec<Uuid>>, // If set, only these specific series (takes precedence over library_id) + #[serde(default)] + force: bool, // If true, regenerate all thumbnails; if false, only missing ones + }, + + /// Find and catalog duplicate books across all libraries + FindDuplicates, + + /// Clean up files for a deleted book (thumbnail + cover references) + CleanupBookFiles { + #[serde(rename = "bookId")] + book_id: Uuid, + /// Optional thumbnail path (if known at deletion time) + #[serde(rename = "thumbnailPath", default)] + thumbnail_path: Option<String>, + /// Optional series_id to invalidate series thumbnail cache + #[serde(rename = "seriesId", default)] + series_id: Option<Uuid>, + }, + + /// Clean up files for a deleted series (cover files) + CleanupSeriesFiles { + #[serde(rename = "seriesId")] + series_id: Uuid, + }, + + /// Scan filesystem for orphaned files and delete them + CleanupOrphanedFiles, + + /// Clean up old pages from the PDF page cache + CleanupPdfCache, + + /// Auto-match metadata for a series using a plugin + PluginAutoMatch { + #[serde(rename = "seriesId")] + series_id: Uuid, + #[serde(rename = "pluginId")] + plugin_id: Uuid, + /// Source scope that triggered this task (for tracking) + #[serde(rename = "sourceScope", default)] + source_scope: Option<String>, // "series:detail", "series:bulk", "library:detail", "library:scan" + }, + + /// Reprocess a single series title using library preprocessing rules + ReprocessSeriesTitle { + #[serde(rename = "seriesId")] + series_id: Uuid, + }, + + /// Reprocess series titles in a scope (library, bulk selection, or specific series) + /// This is a fan-out task that enqueues individual ReprocessSeriesTitle tasks + ReprocessSeriesTitles { + #[serde(rename = "libraryId")] + library_id: Option<Uuid>, // If set, process all series in this library + #[serde(rename = "seriesIds", default)] + series_ids: Option<Vec<Uuid>>, // If set, process only these specific series (bulk selection) + }, + + /// Renumber books in a single series using the library's number strategy + RenumberSeries { + #[serde(rename = "seriesId")] + series_id: Uuid, + }, + + /// Renumber books in multiple series (fan-out task) + /// This is a fan-out task that enqueues individual RenumberSeries tasks + RenumberSeriesBatch { + #[serde(rename = "seriesIds", default)] + series_ids: Option<Vec<Uuid>>, + }, + + /// Clean up expired plugin storage data across all user plugins + CleanupPluginData, + + /// Clean up expired series exports (files + DB records) + CleanupSeriesExports, + + /// Clean up expired and old-revoked refresh tokens. + /// + /// Deletes any `refresh_tokens` row whose `expires_at` is in the past, plus + /// rows that were revoked more than 30 days ago. Idempotent. + CleanupRefreshTokens, + + /// Sync user plugin data with external service + UserPluginSync { + #[serde(rename = "pluginId")] + plugin_id: Uuid, + #[serde(rename = "userId")] + user_id: Uuid, + }, + + /// Refresh recommendations from a user plugin + UserPluginRecommendations { + #[serde(rename = "pluginId")] + plugin_id: Uuid, + #[serde(rename = "userId")] + user_id: Uuid, + }, + + /// Export series data to a JSON or CSV file + ExportSeries { + #[serde(rename = "exportId")] + export_id: Uuid, + #[serde(rename = "userId")] + user_id: Uuid, + }, + + /// Notify a plugin that a recommendation was dismissed + UserPluginRecommendationDismiss { + #[serde(rename = "pluginId")] + plugin_id: Uuid, + #[serde(rename = "userId")] + user_id: Uuid, + #[serde(rename = "externalId")] + external_id: String, + #[serde(default)] + reason: Option<String>, + }, + + /// Backfill release-tracking aliases from existing series metadata. + /// + /// Walks series in scope, harvests the canonical title plus alternate titles + /// from `series_metadata` and `series_alternate_titles`, and seeds them as + /// `metadata`-source aliases in `series_aliases`. Idempotent — re-runs do + /// not create duplicates. Does NOT enable tracking; that stays explicit. + BackfillTrackingFromMetadata { + /// If set, scope to this library; otherwise all series. + #[serde(rename = "libraryId", default)] + library_id: Option<Uuid>, + /// If set, scope to these specific series (takes precedence over library_id). + #[serde(rename = "seriesIds", default)] + series_ids: Option<Vec<Uuid>>, + }, + + /// Poll a single `release_sources` row for new releases. + /// + /// Resolves the source's owning plugin, calls `releases/poll` over the + /// existing plugin host, runs returned candidates through the matcher + + /// threshold, and writes accepted candidates to the ledger. On success + /// updates `last_polled_at` (and optionally `etag`); on failure records + /// `last_error`. Idempotent: ledger writes dedup on + /// `(source_id, external_release_id)` and `info_hash`. + PollReleaseSource { + #[serde(rename = "sourceId")] + source_id: Uuid, + }, + + /// Bulk-toggle `series_tracking.tracked` across many series. + /// + /// On `tracked: true`, runs `seed_tracking_for_series` per series before + /// flipping the flag (same order as the per-series PATCH and the legacy + /// sync bulk endpoint). On `tracked: false`, only flips the flag — + /// aliases and other config are preserved so re-tracking is non- + /// destructive. Per-series `SeriesUpdated` events are emitted from the + /// handler so the UI updates live as the task processes each series. + BulkTrackForReleases { + #[serde(rename = "seriesIds")] + series_ids: Vec<Uuid>, + tracked: bool, + }, +} + +fn default_mode() -> String { + "normal".to_string() +} + +impl TaskType { + /// Returns the default priority for this task type. + /// + /// Higher values = more urgent. Uses large gaps for future insertions. + /// Categories: + /// 1000-900: Scanning (library discovery, post-scan cleanup) + /// 800-750: Analysis (book/series analysis, title reprocessing) + /// 600-570: Thumbnails (single and batch generation) + /// 400-380: Metadata (deduplication, external lookups, plugin matching) + /// 200-180: Plugins (user-facing plugin operations) + /// 100: Cleanup (low-priority maintenance) + pub fn default_priority(&self) -> i32 { + match self { + // Scanning + TaskType::ScanLibrary { .. } => 1000, + TaskType::PurgeDeleted { .. } => 900, + // Analysis + TaskType::AnalyzeBook { .. } => 800, + TaskType::AnalyzeSeries { .. } => 790, + TaskType::ReprocessSeriesTitle { .. } => 780, + TaskType::ReprocessSeriesTitles { .. } => 770, + TaskType::RenumberSeries { .. } => 760, + TaskType::RenumberSeriesBatch { .. } => 750, + // Thumbnails + TaskType::GenerateThumbnail { .. } => 600, + TaskType::GenerateSeriesThumbnail { .. } => 590, + TaskType::GenerateThumbnails { .. } => 580, + TaskType::GenerateSeriesThumbnails { .. } => 570, + // Metadata + TaskType::FindDuplicates => 400, + TaskType::RefreshMetadata { .. } => 390, + TaskType::RefreshLibraryMetadata { .. } => 385, + TaskType::PluginAutoMatch { .. } => 380, + // Export + TaskType::ExportSeries { .. } => 450, + // Plugins + TaskType::UserPluginRecommendationDismiss { .. } => 200, + TaskType::UserPluginSync { .. } => 190, + TaskType::UserPluginRecommendations { .. } => 180, + // Release tracking maintenance + TaskType::BackfillTrackingFromMetadata { .. } => 150, + // User-initiated bulk track/untrack: above the maintenance + // backfill but below scheduled release polling. + TaskType::BulkTrackForReleases { .. } => 155, + // Release polling: scheduled background discovery + TaskType::PollReleaseSource { .. } => 170, + // Cleanup + TaskType::CleanupBookFiles { .. } + | TaskType::CleanupSeriesFiles { .. } + | TaskType::CleanupOrphanedFiles + | TaskType::CleanupPdfCache + | TaskType::CleanupPluginData + | TaskType::CleanupSeriesExports + | TaskType::CleanupRefreshTokens => 100, + } + } + + /// Extract task type string for database storage + pub fn type_string(&self) -> &'static str { + match self { + TaskType::ScanLibrary { .. } => "scan_library", + TaskType::AnalyzeBook { .. } => "analyze_book", + TaskType::AnalyzeSeries { .. } => "analyze_series", + TaskType::PurgeDeleted { .. } => "purge_deleted", + TaskType::RefreshMetadata { .. } => "refresh_metadata", + TaskType::RefreshLibraryMetadata { .. } => "refresh_library_metadata", + TaskType::GenerateThumbnails { .. } => "generate_thumbnails", + TaskType::GenerateThumbnail { .. } => "generate_thumbnail", + TaskType::GenerateSeriesThumbnail { .. } => "generate_series_thumbnail", + TaskType::GenerateSeriesThumbnails { .. } => "generate_series_thumbnails", + TaskType::FindDuplicates => "find_duplicates", + TaskType::CleanupBookFiles { .. } => "cleanup_book_files", + TaskType::CleanupSeriesFiles { .. } => "cleanup_series_files", + TaskType::CleanupOrphanedFiles => "cleanup_orphaned_files", + TaskType::CleanupPdfCache => "cleanup_pdf_cache", + TaskType::PluginAutoMatch { .. } => "plugin_auto_match", + TaskType::ReprocessSeriesTitle { .. } => "reprocess_series_title", + TaskType::ReprocessSeriesTitles { .. } => "reprocess_series_titles", + TaskType::RenumberSeries { .. } => "renumber_series", + TaskType::RenumberSeriesBatch { .. } => "renumber_series_batch", + TaskType::CleanupPluginData => "cleanup_plugin_data", + TaskType::CleanupSeriesExports => "cleanup_series_exports", + TaskType::CleanupRefreshTokens => "cleanup_refresh_tokens", + TaskType::ExportSeries { .. } => "export_series", + TaskType::UserPluginSync { .. } => "user_plugin_sync", + TaskType::UserPluginRecommendations { .. } => "user_plugin_recommendations", + TaskType::UserPluginRecommendationDismiss { .. } => { + "user_plugin_recommendation_dismiss" + } + TaskType::BackfillTrackingFromMetadata { .. } => "backfill_tracking_from_metadata", + TaskType::PollReleaseSource { .. } => "poll_release_source", + TaskType::BulkTrackForReleases { .. } => "bulk_track_for_releases", + } + } + + /// Extract library_id if present. + /// + /// `RefreshLibraryMetadata` carries `job_id` rather than `library_id`; the + /// library is resolved at run time from the job row. The library scope is + /// reflected by `enqueue_filter_library_id` on enqueue; this helper + /// returns `None` for that variant. + pub fn library_id(&self) -> Option<Uuid> { + match self { + TaskType::ScanLibrary { library_id, .. } => Some(*library_id), + TaskType::PurgeDeleted { library_id } => Some(*library_id), + TaskType::GenerateThumbnails { library_id, .. } => *library_id, + TaskType::GenerateSeriesThumbnails { library_id, .. } => *library_id, + TaskType::ReprocessSeriesTitles { library_id, .. } => *library_id, + TaskType::BackfillTrackingFromMetadata { library_id, .. } => *library_id, + _ => None, + } + } + + /// Extract the library job ID for tasks scoped to a single + /// [`library_jobs`] row, if any. + pub fn job_id(&self) -> Option<Uuid> { + match self { + TaskType::RefreshLibraryMetadata { job_id } => Some(*job_id), + _ => None, + } + } + + /// Get task-specific parameters as JSON + pub fn params(&self) -> serde_json::Value { + match self { + TaskType::ScanLibrary { mode, .. } => { + serde_json::json!({ "mode": mode }) + } + TaskType::AnalyzeBook { force, .. } => { + serde_json::json!({ "force": force }) + } + TaskType::AnalyzeSeries { .. } => { + serde_json::json!({}) + } + TaskType::RefreshMetadata { source, .. } => { + serde_json::json!({ "source": source }) + } + TaskType::RefreshLibraryMetadata { job_id } => { + // job_id is stored in params (no FK column on tasks). + // The handler resolves the library from the job row at run time. + serde_json::json!({ "job_id": job_id }) + } + TaskType::GenerateThumbnails { + force, + book_ids, + series_ids, + .. + } => { + serde_json::json!({ "force": force, "book_ids": book_ids, "series_ids": series_ids }) + } + TaskType::GenerateThumbnail { force, .. } => { + serde_json::json!({ "force": force }) + } + TaskType::GenerateSeriesThumbnail { force, .. } => { + serde_json::json!({ "force": force }) + } + TaskType::GenerateSeriesThumbnails { + force, series_ids, .. + } => { + serde_json::json!({ "force": force, "series_ids": series_ids }) + } + TaskType::CleanupBookFiles { + book_id, + thumbnail_path, + series_id, + } => { + // Store book_id in params since the FK column can't reference deleted books + serde_json::json!({ "book_id": book_id, "thumbnail_path": thumbnail_path, "series_id": series_id }) + } + TaskType::CleanupSeriesFiles { series_id } => { + // Store series_id in params since the FK column can't reference deleted series + serde_json::json!({ "series_id": series_id }) + } + TaskType::PluginAutoMatch { + plugin_id, + source_scope, + .. + } => { + serde_json::json!({ "plugin_id": plugin_id, "source_scope": source_scope }) + } + TaskType::ReprocessSeriesTitles { series_ids, .. } => { + serde_json::json!({ "series_ids": series_ids }) + } + TaskType::RenumberSeriesBatch { series_ids } => { + serde_json::json!({ "series_ids": series_ids }) + } + TaskType::UserPluginSync { plugin_id, user_id } => { + serde_json::json!({ "plugin_id": plugin_id, "user_id": user_id }) + } + TaskType::UserPluginRecommendations { plugin_id, user_id } => { + serde_json::json!({ "plugin_id": plugin_id, "user_id": user_id }) + } + TaskType::ExportSeries { export_id, user_id } => { + serde_json::json!({ "export_id": export_id, "user_id": user_id }) + } + TaskType::UserPluginRecommendationDismiss { + plugin_id, + user_id, + external_id, + reason, + } => { + serde_json::json!({ + "plugin_id": plugin_id, + "user_id": user_id, + "external_id": external_id, + "reason": reason, + }) + } + TaskType::BackfillTrackingFromMetadata { series_ids, .. } => { + serde_json::json!({ "series_ids": series_ids }) + } + TaskType::PollReleaseSource { source_id } => { + serde_json::json!({ "source_id": source_id }) + } + TaskType::BulkTrackForReleases { + series_ids, + tracked, + } => { + serde_json::json!({ "series_ids": series_ids, "tracked": tracked }) + } + _ => serde_json::json!({}), + } + } + + /// Extract series_id if present + /// Note: CleanupSeriesFiles stores series_id in params, not as FK (entity may be deleted) + pub fn series_id(&self) -> Option<Uuid> { + match self { + TaskType::AnalyzeSeries { series_id, .. } => Some(*series_id), + TaskType::GenerateThumbnails { series_id, .. } => *series_id, + TaskType::GenerateSeriesThumbnail { series_id, .. } => Some(*series_id), + TaskType::PluginAutoMatch { series_id, .. } => Some(*series_id), + TaskType::ReprocessSeriesTitle { series_id } => Some(*series_id), + TaskType::RenumberSeries { series_id } => Some(*series_id), + // CleanupSeriesFiles intentionally NOT included - series_id is stored in params + // because the series may already be deleted when the task runs + _ => None, + } + } + + /// Extract book_id if present + /// Note: CleanupBookFiles stores book_id in params, not as FK (entity may be deleted) + pub fn book_id(&self) -> Option<Uuid> { + match self { + TaskType::AnalyzeBook { book_id, .. } => Some(*book_id), + TaskType::RefreshMetadata { book_id, .. } => Some(*book_id), + TaskType::GenerateThumbnail { book_id, .. } => Some(*book_id), + // CleanupBookFiles intentionally NOT included - book_id is stored in params + // because the book is already deleted when the cleanup task runs + _ => None, + } + } + + /// JSON-param key/value pair to use as a dedup discriminator for task + /// types whose identity lives in `params` rather than in FK columns. + /// + /// Returning `Some((key, value))` tells the dedup path in + /// `TaskRepository::find_existing_task` to additionally filter by + /// `params->>key = value`. Without this, two `poll_release_source` tasks + /// for *different* `source_id`s would falsely collide because they share + /// the same `task_type` and have no FK columns set, causing the second + /// "Poll now" click to be silently coalesced onto the first source's + /// in-flight poll. + /// + /// `key` must be a simple identifier (alphanumeric + underscore) since + /// SQLite splices it into a JSON path string. + pub fn dedup_params(&self) -> Option<(&'static str, String)> { + match self { + TaskType::PollReleaseSource { source_id } => Some(("source_id", source_id.to_string())), + _ => None, + } + } + + /// Extract all fields needed for database insertion + /// Returns: (type_string, library_id, series_id, book_id, params) + pub fn extract_fields( + &self, + ) -> ( + &'static str, + Option<Uuid>, + Option<Uuid>, + Option<Uuid>, + Option<serde_json::Value>, + ) { + let type_str = self.type_string(); + let library_id = self.library_id(); + let series_id = self.series_id(); + let book_id = self.book_id(); + let params = self.params(); + + let params_value = if params.is_null() || params.as_object().is_some_and(|o| o.is_empty()) { + None + } else { + Some(params) + }; + + (type_str, library_id, series_id, book_id, params_value) + } +} + +/// Task execution result +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TaskResult { + pub success: bool, + pub message: Option<String>, + pub data: Option<serde_json::Value>, +} + +impl TaskResult { + pub fn success(message: impl Into<String>) -> Self { + Self { + success: true, + message: Some(message.into()), + data: None, + } + } + + pub fn success_with_data(message: impl Into<String>, data: serde_json::Value) -> Self { + Self { + success: true, + message: Some(message.into()), + data: Some(data), + } + } + + pub fn failure(message: impl Into<String>) -> Self { + Self { + success: false, + message: Some(message.into()), + data: None, + } + } +} + +/// Task queue statistics +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct TaskStats { + /// Total counts across all task types + pub pending: u64, + pub processing: u64, + pub completed: u64, + pub failed: u64, + pub stale: u64, + pub total: u64, + /// Breakdown by task type and status + pub by_type: std::collections::HashMap<String, TaskTypeStats>, +} + +/// Statistics for a specific task type +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct TaskTypeStats { + pub pending: u64, + pub processing: u64, + pub completed: u64, + pub failed: u64, + pub stale: u64, + pub total: u64, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_task_type_extraction() { + let library_id = Uuid::new_v4(); + let task = TaskType::ScanLibrary { + library_id, + mode: "deep".to_string(), + }; + + assert_eq!(task.type_string(), "scan_library"); + + let (type_str, lib_id, series_id, book_id, params) = task.extract_fields(); + assert_eq!(type_str, "scan_library"); + assert_eq!(lib_id, Some(library_id)); + assert_eq!(series_id, None); + assert_eq!(book_id, None); + assert!(params.is_some()); + } + + #[test] + fn test_analyze_book_extraction() { + let book_id = Uuid::new_v4(); + let task = TaskType::AnalyzeBook { + book_id, + force: false, + }; + + assert_eq!(task.type_string(), "analyze_book"); + + let (_, lib_id, series_id, extracted_book_id, params) = task.extract_fields(); + assert_eq!(lib_id, None); + assert_eq!(series_id, None); + assert_eq!(extracted_book_id, Some(book_id)); + assert!(params.is_some()); + } + + #[test] + fn test_task_result_success() { + let result = TaskResult::success("Task completed"); + assert!(result.success); + assert_eq!(result.message, Some("Task completed".to_string())); + assert!(result.data.is_none()); + } + + #[test] + fn test_task_result_failure() { + let result = TaskResult::failure("Task failed"); + assert!(!result.success); + assert_eq!(result.message, Some("Task failed".to_string())); + } + + #[test] + fn test_task_result_with_data() { + use serde_json::json; + let data = json!({"count": 42}); + let result = TaskResult::success_with_data("Done", data.clone()); + assert!(result.success); + assert_eq!(result.data, Some(data)); + } + + #[test] + fn test_task_stats_total() { + use std::collections::HashMap; + + let stats = TaskStats { + pending: 5, + processing: 3, + completed: 10, + failed: 2, + stale: 1, + total: 21, + by_type: HashMap::new(), + }; + assert_eq!(stats.total, 21); + assert_eq!( + stats.pending + stats.processing + stats.completed + stats.failed, + 20 + ); + } + + #[test] + fn test_generate_thumbnails_extraction() { + let library_id = Uuid::new_v4(); + let series_id = Uuid::new_v4(); + + // Library scope + let task = TaskType::GenerateThumbnails { + library_id: Some(library_id), + series_id: None, + series_ids: None, + book_ids: None, + force: false, + }; + assert_eq!(task.type_string(), "generate_thumbnails"); + assert_eq!(task.library_id(), Some(library_id)); + assert_eq!(task.series_id(), None); + assert_eq!(task.book_id(), None); + + let params = task.params(); + assert_eq!(params["force"], false); + + // Series scope + let task = TaskType::GenerateThumbnails { + library_id: None, + series_id: Some(series_id), + series_ids: None, + book_ids: None, + force: true, + }; + assert_eq!(task.library_id(), None); + assert_eq!(task.series_id(), Some(series_id)); + + let params = task.params(); + assert_eq!(params["force"], true); + + // All scope + let task = TaskType::GenerateThumbnails { + library_id: None, + series_id: None, + series_ids: None, + book_ids: None, + force: false, + }; + assert_eq!(task.library_id(), None); + assert_eq!(task.series_id(), None); + } + + #[test] + fn test_generate_thumbnail_extraction() { + let book_id = Uuid::new_v4(); + + let task = TaskType::GenerateThumbnail { + book_id, + force: true, + }; + + assert_eq!(task.type_string(), "generate_thumbnail"); + assert_eq!(task.library_id(), None); + assert_eq!(task.series_id(), None); + assert_eq!(task.book_id(), Some(book_id)); + + let params = task.params(); + assert_eq!(params["force"], true); + + // Test with force=false + let task = TaskType::GenerateThumbnail { + book_id, + force: false, + }; + let params = task.params(); + assert_eq!(params["force"], false); + } + + #[test] + fn test_generate_thumbnail_extract_fields() { + let book_id = Uuid::new_v4(); + + let task = TaskType::GenerateThumbnail { + book_id, + force: true, + }; + + let (type_str, lib_id, series_id, extracted_book_id, params) = task.extract_fields(); + assert_eq!(type_str, "generate_thumbnail"); + assert_eq!(lib_id, None); + assert_eq!(series_id, None); + assert_eq!(extracted_book_id, Some(book_id)); + assert!(params.is_some()); + assert_eq!(params.unwrap()["force"], true); + } + + #[test] + fn test_generate_thumbnails_extract_fields() { + let library_id = Uuid::new_v4(); + let series_id = Uuid::new_v4(); + + // With series_id (takes precedence) + let task = TaskType::GenerateThumbnails { + library_id: Some(library_id), + series_id: Some(series_id), + series_ids: None, + book_ids: None, + force: true, + }; + + let (type_str, lib_id, extracted_series_id, book_id, params) = task.extract_fields(); + assert_eq!(type_str, "generate_thumbnails"); + assert_eq!(lib_id, Some(library_id)); + assert_eq!(extracted_series_id, Some(series_id)); + assert_eq!(book_id, None); + assert!(params.is_some()); + assert_eq!(params.unwrap()["force"], true); + } + + #[test] + fn test_generate_series_thumbnails_extraction() { + let library_id = Uuid::new_v4(); + + // Library scope + let task = TaskType::GenerateSeriesThumbnails { + library_id: Some(library_id), + series_ids: None, + force: false, + }; + assert_eq!(task.type_string(), "generate_series_thumbnails"); + assert_eq!(task.library_id(), Some(library_id)); + assert_eq!(task.series_id(), None); + assert_eq!(task.book_id(), None); + + let params = task.params(); + assert_eq!(params["force"], false); + + // All scope + let task = TaskType::GenerateSeriesThumbnails { + library_id: None, + series_ids: None, + force: true, + }; + assert_eq!(task.library_id(), None); + assert_eq!(task.series_id(), None); + + let params = task.params(); + assert_eq!(params["force"], true); + } + + #[test] + fn test_generate_series_thumbnails_extract_fields() { + let library_id = Uuid::new_v4(); + + let task = TaskType::GenerateSeriesThumbnails { + library_id: Some(library_id), + series_ids: None, + force: true, + }; + + let (type_str, lib_id, series_id, book_id, params) = task.extract_fields(); + assert_eq!(type_str, "generate_series_thumbnails"); + assert_eq!(lib_id, Some(library_id)); + assert_eq!(series_id, None); + assert_eq!(book_id, None); + assert!(params.is_some()); + assert_eq!(params.unwrap()["force"], true); + } + + #[test] + fn test_cleanup_book_files_extraction() { + let book_id = Uuid::new_v4(); + let series_id = Uuid::new_v4(); + + // Without thumbnail path or series_id + let task = TaskType::CleanupBookFiles { + book_id, + thumbnail_path: None, + series_id: None, + }; + + assert_eq!(task.type_string(), "cleanup_book_files"); + // book_id is NOT returned from book_id() - it's stored in params because + // cleanup tasks reference deleted books that can't have FK constraints + assert_eq!(task.book_id(), None); + assert_eq!(task.library_id(), None); + assert_eq!(task.series_id(), None); + + let (type_str, lib_id, extracted_series_id, extracted_book_id, params) = + task.extract_fields(); + assert_eq!(type_str, "cleanup_book_files"); + assert_eq!(lib_id, None); + assert_eq!(extracted_series_id, None); + assert_eq!(extracted_book_id, None); // Not using FK column + // params should contain book_id, thumbnail_path, and series_id + assert!(params.is_some()); + let params = params.unwrap(); + assert_eq!(params["book_id"], book_id.to_string()); + assert!(params["thumbnail_path"].is_null()); + assert!(params["series_id"].is_null()); + + // With thumbnail path and series_id + let task = TaskType::CleanupBookFiles { + book_id, + thumbnail_path: Some("/data/thumbnails/books/ab/abc123.jpg".to_string()), + series_id: Some(series_id), + }; + + let params = task.params(); + assert_eq!(params["book_id"], book_id.to_string()); + assert_eq!( + params["thumbnail_path"], + "/data/thumbnails/books/ab/abc123.jpg" + ); + assert_eq!(params["series_id"], series_id.to_string()); + } + + #[test] + fn test_cleanup_series_files_extraction() { + let series_id = Uuid::new_v4(); + + let task = TaskType::CleanupSeriesFiles { series_id }; + + assert_eq!(task.type_string(), "cleanup_series_files"); + // series_id is NOT returned from series_id() - it's stored in params because + // cleanup tasks reference deleted series that can't have FK constraints + assert_eq!(task.series_id(), None); + assert_eq!(task.book_id(), None); + assert_eq!(task.library_id(), None); + + let (type_str, lib_id, extracted_series_id, book_id, params) = task.extract_fields(); + assert_eq!(type_str, "cleanup_series_files"); + assert_eq!(lib_id, None); + assert_eq!(extracted_series_id, None); // Not using FK column + assert_eq!(book_id, None); + // params should contain series_id + assert!(params.is_some()); + let params = params.unwrap(); + assert_eq!(params["series_id"], series_id.to_string()); + } + + #[test] + fn test_cleanup_orphaned_files_extraction() { + let task = TaskType::CleanupOrphanedFiles; + + assert_eq!(task.type_string(), "cleanup_orphaned_files"); + assert_eq!(task.library_id(), None); + assert_eq!(task.series_id(), None); + assert_eq!(task.book_id(), None); + + let (type_str, lib_id, series_id, book_id, params) = task.extract_fields(); + assert_eq!(type_str, "cleanup_orphaned_files"); + assert_eq!(lib_id, None); + assert_eq!(series_id, None); + assert_eq!(book_id, None); + assert!(params.is_none()); + } + + #[test] + fn test_cleanup_task_serialization() { + let book_id = Uuid::new_v4(); + let series_id = Uuid::new_v4(); + + // Test CleanupBookFiles serialization + let task = TaskType::CleanupBookFiles { + book_id, + thumbnail_path: Some("/path/to/thumb.jpg".to_string()), + series_id: Some(series_id), + }; + let json = serde_json::to_string(&task).unwrap(); + assert!(json.contains("cleanup_book_files")); + assert!(json.contains(&book_id.to_string())); + + // Test deserialization + let deserialized: TaskType = serde_json::from_str(&json).unwrap(); + assert_eq!(deserialized.type_string(), "cleanup_book_files"); + + // Test CleanupSeriesFiles serialization + let task = TaskType::CleanupSeriesFiles { series_id }; + let json = serde_json::to_string(&task).unwrap(); + assert!(json.contains("cleanup_series_files")); + + // Test CleanupOrphanedFiles serialization + let task = TaskType::CleanupOrphanedFiles; + let json = serde_json::to_string(&task).unwrap(); + assert!(json.contains("cleanup_orphaned_files")); + + let deserialized: TaskType = serde_json::from_str(&json).unwrap(); + assert_eq!(deserialized.type_string(), "cleanup_orphaned_files"); + } + + #[test] + fn test_user_plugin_recommendation_dismiss_extraction() { + let plugin_id = Uuid::new_v4(); + let user_id = Uuid::new_v4(); + + let task = TaskType::UserPluginRecommendationDismiss { + plugin_id, + user_id, + external_id: "12345".to_string(), + reason: Some("not_interested".to_string()), + }; + + assert_eq!(task.type_string(), "user_plugin_recommendation_dismiss"); + assert_eq!(task.library_id(), None); + assert_eq!(task.series_id(), None); + assert_eq!(task.book_id(), None); + + let params = task.params(); + assert_eq!(params["plugin_id"], plugin_id.to_string()); + assert_eq!(params["user_id"], user_id.to_string()); + assert_eq!(params["external_id"], "12345"); + assert_eq!(params["reason"], "not_interested"); + } + + #[test] + fn test_user_plugin_recommendation_dismiss_extract_fields() { + let plugin_id = Uuid::new_v4(); + let user_id = Uuid::new_v4(); + + let task = TaskType::UserPluginRecommendationDismiss { + plugin_id, + user_id, + external_id: "99".to_string(), + reason: None, + }; + + let (type_str, lib_id, series_id, book_id, params) = task.extract_fields(); + assert_eq!(type_str, "user_plugin_recommendation_dismiss"); + assert_eq!(lib_id, None); + assert_eq!(series_id, None); + assert_eq!(book_id, None); + assert!(params.is_some()); + let params = params.unwrap(); + assert_eq!(params["external_id"], "99"); + assert!(params["reason"].is_null()); + } + + #[test] + fn test_user_plugin_recommendation_dismiss_serialization() { + let plugin_id = Uuid::new_v4(); + let user_id = Uuid::new_v4(); + + let task = TaskType::UserPluginRecommendationDismiss { + plugin_id, + user_id, + external_id: "12345".to_string(), + reason: Some("already_read".to_string()), + }; + + let json = serde_json::to_string(&task).unwrap(); + assert!(json.contains("user_plugin_recommendation_dismiss")); + assert!(json.contains(&plugin_id.to_string())); + assert!(json.contains("12345")); + + let deserialized: TaskType = serde_json::from_str(&json).unwrap(); + assert_eq!( + deserialized.type_string(), + "user_plugin_recommendation_dismiss" + ); + } + + #[test] + fn test_renumber_series_extraction() { + let series_id = Uuid::new_v4(); + + let task = TaskType::RenumberSeries { series_id }; + + assert_eq!(task.type_string(), "renumber_series"); + assert_eq!(task.library_id(), None); + assert_eq!(task.series_id(), Some(series_id)); + assert_eq!(task.book_id(), None); + + let (type_str, lib_id, extracted_series_id, book_id, params) = task.extract_fields(); + assert_eq!(type_str, "renumber_series"); + assert_eq!(lib_id, None); + assert_eq!(extracted_series_id, Some(series_id)); + assert_eq!(book_id, None); + // RenumberSeries has no special params, so params should be None (empty object) + assert!(params.is_none()); + } + + #[test] + fn test_renumber_series_serialization() { + let series_id = Uuid::new_v4(); + + let task = TaskType::RenumberSeries { series_id }; + let json = serde_json::to_string(&task).unwrap(); + assert!(json.contains("renumber_series")); + assert!(json.contains(&series_id.to_string())); + + let deserialized: TaskType = serde_json::from_str(&json).unwrap(); + assert_eq!(deserialized.type_string(), "renumber_series"); + assert_eq!(deserialized.series_id(), Some(series_id)); + } + + #[test] + fn test_renumber_series_batch_extraction() { + let id1 = Uuid::new_v4(); + let id2 = Uuid::new_v4(); + + let task = TaskType::RenumberSeriesBatch { + series_ids: Some(vec![id1, id2]), + }; + + assert_eq!(task.type_string(), "renumber_series_batch"); + assert_eq!(task.library_id(), None); + assert_eq!(task.series_id(), None); + assert_eq!(task.book_id(), None); + + let (type_str, lib_id, series_id, book_id, params) = task.extract_fields(); + assert_eq!(type_str, "renumber_series_batch"); + assert_eq!(lib_id, None); + assert_eq!(series_id, None); + assert_eq!(book_id, None); + assert!(params.is_some()); + let params = params.unwrap(); + let ids = params["series_ids"].as_array().unwrap(); + assert_eq!(ids.len(), 2); + } + + #[test] + fn test_renumber_series_batch_empty() { + // Batch with None series_ids + let task = TaskType::RenumberSeriesBatch { series_ids: None }; + + assert_eq!(task.type_string(), "renumber_series_batch"); + let params = task.params(); + assert!(params["series_ids"].is_null()); + } + + #[test] + fn test_refresh_library_metadata_extraction() { + let job_id = Uuid::new_v4(); + let task = TaskType::RefreshLibraryMetadata { job_id }; + + assert_eq!(task.type_string(), "refresh_library_metadata"); + // RefreshLibraryMetadata is scoped by job_id; library is resolved at runtime. + assert_eq!(task.library_id(), None); + assert_eq!(task.job_id(), Some(job_id)); + assert_eq!(task.series_id(), None); + assert_eq!(task.book_id(), None); + assert_eq!(task.default_priority(), 385); + + let (type_str, lib_id, series_id, book_id, params) = task.extract_fields(); + assert_eq!(type_str, "refresh_library_metadata"); + assert!(lib_id.is_none()); + assert!(series_id.is_none()); + assert!(book_id.is_none()); + // job_id is part of the params payload (no dedicated FK column on tasks) + let params = params.expect("expected job_id params"); + assert_eq!(params["job_id"], serde_json::json!(job_id)); + } + + #[test] + fn test_refresh_library_metadata_serialization() { + let job_id = Uuid::new_v4(); + let task = TaskType::RefreshLibraryMetadata { job_id }; + + let json = serde_json::to_string(&task).unwrap(); + assert!(json.contains("refresh_library_metadata")); + assert!(json.contains(&job_id.to_string())); + // jobId is the camelCase rename for the new variant. + assert!(json.contains("jobId")); + + let deserialized: TaskType = serde_json::from_str(&json).unwrap(); + assert_eq!(deserialized.type_string(), "refresh_library_metadata"); + assert_eq!(deserialized.job_id(), Some(job_id)); + } + + #[test] + fn test_poll_release_source_extraction() { + let source_id = Uuid::new_v4(); + let task = TaskType::PollReleaseSource { source_id }; + + assert_eq!(task.type_string(), "poll_release_source"); + assert_eq!(task.library_id(), None); + assert_eq!(task.series_id(), None); + assert_eq!(task.book_id(), None); + assert_eq!(task.default_priority(), 170); + + let (type_str, lib_id, series_id, book_id, params) = task.extract_fields(); + assert_eq!(type_str, "poll_release_source"); + assert_eq!(lib_id, None); + assert_eq!(series_id, None); + assert_eq!(book_id, None); + let params = params.expect("expected source_id params"); + assert_eq!(params["source_id"], serde_json::json!(source_id)); + } + + #[test] + fn test_bulk_track_for_releases_extraction() { + let ids = vec![Uuid::new_v4(), Uuid::new_v4()]; + let task = TaskType::BulkTrackForReleases { + series_ids: ids.clone(), + tracked: true, + }; + + assert_eq!(task.type_string(), "bulk_track_for_releases"); + assert_eq!(task.library_id(), None); + assert_eq!(task.series_id(), None); + assert_eq!(task.book_id(), None); + assert_eq!(task.default_priority(), 155); + + let (type_str, lib_id, series_id, book_id, params) = task.extract_fields(); + assert_eq!(type_str, "bulk_track_for_releases"); + assert_eq!(lib_id, None); + assert_eq!(series_id, None); + assert_eq!(book_id, None); + let params = params.expect("expected series_ids + tracked params"); + assert_eq!(params["tracked"], true); + assert_eq!(params["series_ids"].as_array().unwrap().len(), 2); + } + + #[test] + fn test_bulk_track_for_releases_serialization_roundtrip() { + let ids = vec![Uuid::new_v4()]; + let task = TaskType::BulkTrackForReleases { + series_ids: ids.clone(), + tracked: false, + }; + + let json = serde_json::to_string(&task).unwrap(); + assert!(json.contains("bulk_track_for_releases")); + // camelCase rename on the field, snake_case on the discriminator. + assert!(json.contains("seriesIds")); + assert!(json.contains("\"tracked\":false")); + + let deserialized: TaskType = serde_json::from_str(&json).unwrap(); + match deserialized { + TaskType::BulkTrackForReleases { + series_ids, + tracked, + } => { + assert_eq!(series_ids, ids); + assert!(!tracked); + } + _ => panic!("wrong variant"), + } + } + + #[test] + fn test_poll_release_source_serialization() { + let source_id = Uuid::new_v4(); + let task = TaskType::PollReleaseSource { source_id }; + + let json = serde_json::to_string(&task).unwrap(); + assert!(json.contains("poll_release_source")); + assert!(json.contains(&source_id.to_string())); + // sourceId is the camelCase rename. + assert!(json.contains("sourceId")); + + let deserialized: TaskType = serde_json::from_str(&json).unwrap(); + assert_eq!(deserialized.type_string(), "poll_release_source"); + match deserialized { + TaskType::PollReleaseSource { source_id: id } => { + assert_eq!(id, source_id); + } + _ => panic!("wrong variant"), + } + } + + #[test] + fn test_default_priority_values() { + let library_id = Uuid::new_v4(); + let series_id = Uuid::new_v4(); + let book_id = Uuid::new_v4(); + let plugin_id = Uuid::new_v4(); + let user_id = Uuid::new_v4(); + + // Scanning: highest priority + assert_eq!( + TaskType::ScanLibrary { + library_id, + mode: "normal".to_string() + } + .default_priority(), + 1000 + ); + assert_eq!( + TaskType::PurgeDeleted { library_id }.default_priority(), + 900 + ); + + // Analysis + assert_eq!( + TaskType::AnalyzeBook { + book_id, + force: false + } + .default_priority(), + 800 + ); + assert_eq!( + TaskType::AnalyzeSeries { series_id }.default_priority(), + 790 + ); + assert_eq!( + TaskType::ReprocessSeriesTitle { series_id }.default_priority(), + 780 + ); + assert_eq!( + TaskType::ReprocessSeriesTitles { + library_id: Some(library_id), + series_ids: None + } + .default_priority(), + 770 + ); + assert_eq!( + TaskType::RenumberSeries { series_id }.default_priority(), + 760 + ); + assert_eq!( + TaskType::RenumberSeriesBatch { + series_ids: Some(vec![series_id]) + } + .default_priority(), + 750 + ); + + // Thumbnails + assert_eq!( + TaskType::GenerateThumbnail { + book_id, + force: false + } + .default_priority(), + 600 + ); + assert_eq!( + TaskType::GenerateSeriesThumbnail { + series_id, + force: false + } + .default_priority(), + 590 + ); + assert_eq!( + TaskType::GenerateThumbnails { + library_id: Some(library_id), + series_id: None, + series_ids: None, + book_ids: None, + force: false + } + .default_priority(), + 580 + ); + assert_eq!( + TaskType::GenerateSeriesThumbnails { + library_id: Some(library_id), + series_ids: None, + force: false + } + .default_priority(), + 570 + ); + + // Metadata + assert_eq!(TaskType::FindDuplicates.default_priority(), 400); + assert_eq!( + TaskType::RefreshMetadata { + book_id, + source: "test".to_string() + } + .default_priority(), + 390 + ); + assert_eq!( + TaskType::PluginAutoMatch { + series_id, + plugin_id, + source_scope: None + } + .default_priority(), + 380 + ); + + // Plugins + assert_eq!( + TaskType::UserPluginRecommendationDismiss { + plugin_id, + user_id, + external_id: "test".to_string(), + reason: None + } + .default_priority(), + 200 + ); + assert_eq!( + TaskType::UserPluginSync { plugin_id, user_id }.default_priority(), + 190 + ); + assert_eq!( + TaskType::UserPluginRecommendations { plugin_id, user_id }.default_priority(), + 180 + ); + + // Cleanup: lowest priority + assert_eq!( + TaskType::CleanupBookFiles { + book_id, + thumbnail_path: None, + series_id: None + } + .default_priority(), + 100 + ); + assert_eq!( + TaskType::CleanupSeriesFiles { series_id }.default_priority(), + 100 + ); + assert_eq!(TaskType::CleanupOrphanedFiles.default_priority(), 100); + assert_eq!(TaskType::CleanupPdfCache.default_priority(), 100); + assert_eq!(TaskType::CleanupPluginData.default_priority(), 100); + } + + #[test] + fn test_default_priority_ordering_invariants() { + let library_id = Uuid::new_v4(); + let _series_id = Uuid::new_v4(); + let book_id = Uuid::new_v4(); + + // Scanning > Analysis > Thumbnails > Metadata > Plugins > Cleanup + let scan = TaskType::ScanLibrary { + library_id, + mode: "normal".to_string(), + } + .default_priority(); + let analyze = TaskType::AnalyzeBook { + book_id, + force: false, + } + .default_priority(); + let thumbnail = TaskType::GenerateThumbnail { + book_id, + force: false, + } + .default_priority(); + let metadata = TaskType::FindDuplicates.default_priority(); + let plugin = TaskType::UserPluginSync { + plugin_id: Uuid::new_v4(), + user_id: Uuid::new_v4(), + } + .default_priority(); + let cleanup = TaskType::CleanupOrphanedFiles.default_priority(); + + assert!( + scan > analyze, + "Scanning should have higher priority than analysis" + ); + assert!( + analyze > thumbnail, + "Analysis should have higher priority than thumbnails" + ); + assert!( + thumbnail > metadata, + "Thumbnails should have higher priority than metadata" + ); + assert!( + metadata > plugin, + "Metadata should have higher priority than plugins" + ); + assert!( + plugin > cleanup, + "Plugins should have higher priority than cleanup" + ); + } + + #[test] + fn test_renumber_series_batch_serialization() { + let id1 = Uuid::new_v4(); + + let task = TaskType::RenumberSeriesBatch { + series_ids: Some(vec![id1]), + }; + + let json = serde_json::to_string(&task).unwrap(); + assert!(json.contains("renumber_series_batch")); + assert!(json.contains(&id1.to_string())); + + let deserialized: TaskType = serde_json::from_str(&json).unwrap(); + assert_eq!(deserialized.type_string(), "renumber_series_batch"); + } +} diff --git a/src/scheduler/mod.rs b/src/scheduler/mod.rs index 9746a894..3f01c2f4 100644 --- a/src/scheduler/mod.rs +++ b/src/scheduler/mod.rs @@ -742,6 +742,29 @@ impl Scheduler { } } +/// Adapter that lets the `services` layer drive a `Scheduler` through the +/// [`crate::services::scheduler_handle::SchedulerReconciler`] trait without +/// holding the concrete type. The trait inverts the layer dependency so +/// `services` can ask for a reconcile without importing `scheduler`. +pub struct LockedSchedulerReconciler { + inner: std::sync::Arc<tokio::sync::Mutex<Scheduler>>, +} + +impl LockedSchedulerReconciler { + pub fn new(inner: std::sync::Arc<tokio::sync::Mutex<Scheduler>>) -> Self { + Self { inner } + } +} + +impl crate::services::scheduler_handle::SchedulerReconciler for LockedSchedulerReconciler { + fn reconcile_release_sources(&self) -> futures::future::BoxFuture<'_, Result<()>> { + Box::pin(async move { + let mut guard = self.inner.lock().await; + guard.reconcile_release_sources().await + }) + } +} + /// Whether an active (`pending` or `processing`) `refresh_library_metadata` /// task already exists for the given **job**. /// diff --git a/src/services/book_export_collector.rs b/src/services/book_export_collector.rs index 1f4e81f3..98bebd99 100644 --- a/src/services/book_export_collector.rs +++ b/src/services/book_export_collector.rs @@ -11,11 +11,11 @@ use std::collections::HashMap; use std::fmt; use uuid::Uuid; -use crate::api::extractors::content_filter::ContentFilter; use crate::db::entities::{book_metadata, books, read_progress}; use crate::db::repositories::{ GenreRepository, LibraryRepository, ReadProgressRepository, SeriesRepository, TagRepository, }; +use crate::services::content_filter::ContentFilter; // ============================================================================= // BookExportField enum diff --git a/src/services/cleanup_subscriber.rs b/src/services/cleanup_subscriber.rs index e75c9b1c..18ddf2ba 100644 --- a/src/services/cleanup_subscriber.rs +++ b/src/services/cleanup_subscriber.rs @@ -10,7 +10,7 @@ use tracing::{debug, error, info, warn}; use crate::db::repositories::TaskRepository; use crate::events::{EntityChangeEvent, EntityEvent, EventBroadcaster}; -use crate::tasks::types::TaskType; +use crate::models::task::TaskType; /// Service that subscribes to entity events and triggers file cleanup tasks pub struct CleanupEventSubscriber { @@ -191,7 +191,7 @@ mod tests { use super::*; use crate::db::test_helpers::create_test_db; use crate::events::EventBroadcaster; - use crate::tasks::types::TaskType; + use crate::models::task::TaskType; use chrono::Utc; use uuid::Uuid; diff --git a/src/api/extractors/content_filter.rs b/src/services/content_filter.rs similarity index 100% rename from src/api/extractors/content_filter.rs rename to src/services/content_filter.rs diff --git a/src/services/filter.rs b/src/services/filter.rs index 4b39db78..de38537d 100644 --- a/src/services/filter.rs +++ b/src/services/filter.rs @@ -4,11 +4,11 @@ #![allow(dead_code)] -use crate::api::routes::v1::dto::{ +use crate::db::repositories::{GenreRepository, TagRepository}; +use crate::models::filter::{ BookCondition, BoolOperator, DateOperator, FieldOperator, NumberOperator, SeriesCondition, UuidOperator, }; -use crate::db::repositories::{GenreRepository, TagRepository}; use anyhow::Result; use sea_orm::DatabaseConnection; use std::collections::HashSet; @@ -2515,9 +2515,7 @@ impl FilterService { #[cfg(test)] mod tests { use super::*; - use crate::api::routes::v1::dto::{ - BookCondition, FieldOperator, SeriesCondition, UuidOperator, - }; + use crate::models::filter::{BookCondition, FieldOperator, SeriesCondition, UuidOperator}; // Unit tests for condition building and basic logic diff --git a/src/services/metadata/cover.rs b/src/services/metadata/cover.rs index d09764a6..686772bc 100644 --- a/src/services/metadata/cover.rs +++ b/src/services/metadata/cover.rs @@ -13,8 +13,8 @@ use crate::db::repositories::{ BookCoversRepository, SeriesCoversRepository, SeriesRepository, TaskRepository, }; use crate::events::{EntityChangeEvent, EntityEvent, EntityType, EventBroadcaster}; +use crate::models::task::TaskType; use crate::services::ThumbnailService; -use crate::tasks::types::TaskType; /// Service for downloading and applying cover images to series. pub struct CoverService; diff --git a/src/services/metadata/preprocessing/types.rs b/src/services/metadata/preprocessing/types.rs index a35f8c8b..955d21d8 100644 --- a/src/services/metadata/preprocessing/types.rs +++ b/src/services/metadata/preprocessing/types.rs @@ -1,560 +1,8 @@ -//! Types for preprocessing rules and auto-match conditions. +//! Re-export of preprocessing value types. //! -//! This module defines the data structures used for: -//! - Title preprocessing rules (regex-based transformations) -//! - Auto-match conditions (conditional logic for plugin matching) -//! - Condition operators (comparison operations) -//! -//! ## Example: Preprocessing Rules -//! -//! ```json -//! [ -//! { -//! "pattern": "\\s*\\(Digital\\)$", -//! "replacement": "", -//! "description": "Remove (Digital) suffix", -//! "enabled": true -//! } -//! ] -//! ``` -//! -//! ## Example: Auto-Match Conditions -//! -//! ```json -//! { -//! "mode": "all", -//! "rules": [ -//! { -//! "field": "external_ids.plugin:mangabaka", -//! "operator": "is_null" -//! }, -//! { -//! "field": "book_count", -//! "operator": "gte", -//! "value": 1 -//! } -//! ] -//! } -//! ``` - -use serde::{Deserialize, Serialize}; -use serde_json::Value; - -// ============================================================================= -// Preprocessing Rules -// ============================================================================= - -/// A single preprocessing rule that transforms text using regex. -/// -/// Rules are applied in order during scan time to clean up series titles -/// before they are used for metadata searches. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -pub struct PreprocessingRule { - /// Regex pattern to match (uses Rust regex syntax) - pub pattern: String, - - /// Replacement string (supports $1, $2, etc. for capture groups) - pub replacement: String, - - /// Human-readable description of what this rule does - #[serde(default)] - pub description: Option<String>, - - /// Whether this rule is active (default: true) - #[serde(default = "default_enabled")] - pub enabled: bool, -} - -fn default_enabled() -> bool { - true -} - -impl PreprocessingRule { - /// Create a new preprocessing rule. - pub fn new(pattern: impl Into<String>, replacement: impl Into<String>) -> Self { - Self { - pattern: pattern.into(), - replacement: replacement.into(), - description: None, - enabled: true, - } - } - - /// Create a new preprocessing rule with a description. - pub fn with_description( - pattern: impl Into<String>, - replacement: impl Into<String>, - description: impl Into<String>, - ) -> Self { - Self { - pattern: pattern.into(), - replacement: replacement.into(), - description: Some(description.into()), - enabled: true, - } - } -} - -// ============================================================================= -// Auto-Match Conditions -// ============================================================================= - -/// Auto-match conditions that control when plugin matching should occur. -/// -/// Conditions can be configured at both library and plugin levels: -/// - Library conditions are checked first (if any fail, skip auto-match for this library) -/// - Plugin conditions are checked second (if any fail, skip this plugin) -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -pub struct AutoMatchConditions { - /// How to combine the rules: "all" (AND) or "any" (OR) - #[serde(default)] - pub mode: ConditionMode, - - /// List of condition rules to evaluate - #[serde(default)] - pub rules: Vec<ConditionRule>, -} - -impl Default for AutoMatchConditions { - fn default() -> Self { - Self { - mode: ConditionMode::All, - rules: Vec::new(), - } - } -} - -impl AutoMatchConditions { - /// Create new conditions with the given mode. - pub fn new(mode: ConditionMode) -> Self { - Self { - mode, - rules: Vec::new(), - } - } - - /// Add a rule to the conditions. - pub fn with_rule(mut self, rule: ConditionRule) -> Self { - self.rules.push(rule); - self - } - - /// Check if there are any rules to evaluate. - pub fn is_empty(&self) -> bool { - self.rules.is_empty() - } -} - -/// How to combine multiple condition rules. -#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -pub enum ConditionMode { - /// All rules must pass (logical AND) - #[default] - All, - /// Any rule must pass (logical OR) - Any, -} - -/// A single condition rule that evaluates a field against an operator. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -pub struct ConditionRule { - /// Field path to evaluate (e.g., "book_count", "metadata.title", "external_ids.plugin:mangabaka") - pub field: String, - - /// Comparison operator - pub operator: ConditionOperator, - - /// Value to compare against (not required for is_null/is_not_null) - #[serde(default, skip_serializing_if = "Option::is_none")] - pub value: Option<Value>, -} - -impl ConditionRule { - /// Create a new condition rule. - pub fn new(field: impl Into<String>, operator: ConditionOperator) -> Self { - Self { - field: field.into(), - operator, - value: None, - } - } - - /// Create a new condition rule with a value. - pub fn with_value(field: impl Into<String>, operator: ConditionOperator, value: Value) -> Self { - Self { - field: field.into(), - operator, - value: Some(value), - } - } -} - -/// Comparison operators for condition evaluation. -#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -pub enum ConditionOperator { - /// Field is null, empty, or missing - IsNull, - /// Field has a non-null, non-empty value - IsNotNull, - /// Exact match (string or number) - Equals, - /// Not equal - NotEquals, - /// Greater than (numeric) - Gt, - /// Greater than or equal (numeric) - Gte, - /// Less than (numeric) - Lt, - /// Less than or equal (numeric) - Lte, - /// String contains substring - Contains, - /// String does not contain substring - NotContains, - /// String starts with prefix - StartsWith, - /// String ends with suffix - EndsWith, - /// String matches regex pattern - Matches, - /// Value is in the provided array - In, - /// Value is not in the provided array - NotIn, -} - -impl ConditionOperator { - /// Check if this operator requires a value. - pub fn requires_value(&self) -> bool { - !matches!( - self, - ConditionOperator::IsNull | ConditionOperator::IsNotNull - ) - } - - /// Check if this operator is for numeric comparison. - pub fn is_numeric(&self) -> bool { - matches!( - self, - ConditionOperator::Gt - | ConditionOperator::Gte - | ConditionOperator::Lt - | ConditionOperator::Lte - ) - } - - /// Check if this operator is for string comparison. - pub fn is_string(&self) -> bool { - matches!( - self, - ConditionOperator::Contains - | ConditionOperator::NotContains - | ConditionOperator::StartsWith - | ConditionOperator::EndsWith - | ConditionOperator::Matches - ) - } -} - -// ============================================================================= -// Parsing Helpers -// ============================================================================= - -/// Parse preprocessing rules from JSON string. -pub fn parse_preprocessing_rules(json: Option<&str>) -> Result<Vec<PreprocessingRule>, String> { - match json { - None => Ok(Vec::new()), - Some(s) if s.trim().is_empty() => Ok(Vec::new()), - Some(s) => serde_json::from_str(s) - .map_err(|e| format!("Failed to parse preprocessing rules: {}", e)), - } -} - -/// Parse auto-match conditions from JSON string. -pub fn parse_auto_match_conditions( - json: Option<&str>, -) -> Result<Option<AutoMatchConditions>, String> { - match json { - None => Ok(None), - Some(s) if s.trim().is_empty() => Ok(None), - Some(s) => serde_json::from_str(s) - .map(Some) - .map_err(|e| format!("Failed to parse auto-match conditions: {}", e)), - } -} - -// ============================================================================= -// Tests -// ============================================================================= - -#[cfg(test)] -mod tests { - use super::*; - - // ========================================================================= - // PreprocessingRule Tests - // ========================================================================= - - #[test] - fn test_preprocessing_rule_new() { - let rule = PreprocessingRule::new(r"\s*\(Digital\)$", ""); - assert_eq!(rule.pattern, r"\s*\(Digital\)$"); - assert_eq!(rule.replacement, ""); - assert!(rule.description.is_none()); - assert!(rule.enabled); - } - - #[test] - fn test_preprocessing_rule_with_description() { - let rule = - PreprocessingRule::with_description(r"\s*\(Digital\)$", "", "Remove (Digital) suffix"); - assert_eq!( - rule.description, - Some("Remove (Digital) suffix".to_string()) - ); - } - - #[test] - fn test_preprocessing_rule_serialization() { - let rule = PreprocessingRule { - pattern: r"\s*\(Digital\)$".to_string(), - replacement: "".to_string(), - description: Some("Remove (Digital) suffix".to_string()), - enabled: true, - }; - - let json = serde_json::to_string(&rule).unwrap(); - let parsed: PreprocessingRule = serde_json::from_str(&json).unwrap(); - assert_eq!(rule, parsed); - } - - #[test] - fn test_preprocessing_rule_default_enabled() { - // When enabled is not specified, it should default to true - let json = r#"{"pattern": "test", "replacement": ""}"#; - let rule: PreprocessingRule = serde_json::from_str(json).unwrap(); - assert!(rule.enabled); - } - - #[test] - fn test_preprocessing_rules_array() { - let json = r#"[ - {"pattern": "\\s*\\(Digital\\)$", "replacement": ""}, - {"pattern": "\\s+v\\d+$", "replacement": "", "description": "Remove version suffix", "enabled": false} - ]"#; - let rules: Vec<PreprocessingRule> = serde_json::from_str(json).unwrap(); - assert_eq!(rules.len(), 2); - assert!(rules[0].enabled); - assert!(!rules[1].enabled); - } - - // ========================================================================= - // AutoMatchConditions Tests - // ========================================================================= - - #[test] - fn test_auto_match_conditions_default() { - let conditions = AutoMatchConditions::default(); - assert_eq!(conditions.mode, ConditionMode::All); - assert!(conditions.rules.is_empty()); - assert!(conditions.is_empty()); - } - - #[test] - fn test_auto_match_conditions_builder() { - let conditions = AutoMatchConditions::new(ConditionMode::Any) - .with_rule(ConditionRule::new("book_count", ConditionOperator::Gte)) - .with_rule(ConditionRule::new( - "external_ids.count", - ConditionOperator::IsNull, - )); - - assert_eq!(conditions.mode, ConditionMode::Any); - assert_eq!(conditions.rules.len(), 2); - assert!(!conditions.is_empty()); - } - - #[test] - fn test_auto_match_conditions_serialization() { - let conditions = AutoMatchConditions { - mode: ConditionMode::All, - rules: vec![ - ConditionRule { - field: "external_ids.plugin:mangabaka".to_string(), - operator: ConditionOperator::IsNull, - value: None, - }, - ConditionRule { - field: "book_count".to_string(), - operator: ConditionOperator::Gte, - value: Some(serde_json::json!(1)), - }, - ], - }; - - let json = serde_json::to_string_pretty(&conditions).unwrap(); - let parsed: AutoMatchConditions = serde_json::from_str(&json).unwrap(); - assert_eq!(conditions, parsed); - } - - #[test] - fn test_auto_match_conditions_from_json() { - let json = r#"{ - "mode": "all", - "rules": [ - {"field": "external_ids.plugin:mangabaka", "operator": "is_null"}, - {"field": "book_count", "operator": "gte", "value": 1} - ] - }"#; - let conditions: AutoMatchConditions = serde_json::from_str(json).unwrap(); - assert_eq!(conditions.mode, ConditionMode::All); - assert_eq!(conditions.rules.len(), 2); - assert_eq!(conditions.rules[0].operator, ConditionOperator::IsNull); - assert_eq!(conditions.rules[1].operator, ConditionOperator::Gte); - } - - // ========================================================================= - // ConditionRule Tests - // ========================================================================= - - #[test] - fn test_condition_rule_new() { - let rule = ConditionRule::new("book_count", ConditionOperator::IsNull); - assert_eq!(rule.field, "book_count"); - assert_eq!(rule.operator, ConditionOperator::IsNull); - assert!(rule.value.is_none()); - } - - #[test] - fn test_condition_rule_with_value() { - let rule = - ConditionRule::with_value("book_count", ConditionOperator::Gte, serde_json::json!(5)); - assert_eq!(rule.field, "book_count"); - assert_eq!(rule.operator, ConditionOperator::Gte); - assert_eq!(rule.value, Some(serde_json::json!(5))); - } - - // ========================================================================= - // ConditionOperator Tests - // ========================================================================= - - #[test] - fn test_condition_operator_requires_value() { - assert!(!ConditionOperator::IsNull.requires_value()); - assert!(!ConditionOperator::IsNotNull.requires_value()); - assert!(ConditionOperator::Equals.requires_value()); - assert!(ConditionOperator::Gte.requires_value()); - assert!(ConditionOperator::Contains.requires_value()); - } - - #[test] - fn test_condition_operator_is_numeric() { - assert!(ConditionOperator::Gt.is_numeric()); - assert!(ConditionOperator::Gte.is_numeric()); - assert!(ConditionOperator::Lt.is_numeric()); - assert!(ConditionOperator::Lte.is_numeric()); - assert!(!ConditionOperator::Equals.is_numeric()); - assert!(!ConditionOperator::Contains.is_numeric()); - } - - #[test] - fn test_condition_operator_is_string() { - assert!(ConditionOperator::Contains.is_string()); - assert!(ConditionOperator::NotContains.is_string()); - assert!(ConditionOperator::StartsWith.is_string()); - assert!(ConditionOperator::EndsWith.is_string()); - assert!(ConditionOperator::Matches.is_string()); - assert!(!ConditionOperator::Equals.is_string()); - assert!(!ConditionOperator::Gt.is_string()); - } - - #[test] - fn test_condition_operator_serialization() { - let json = serde_json::to_string(&ConditionOperator::IsNull).unwrap(); - assert_eq!(json, "\"is_null\""); - - let json = serde_json::to_string(&ConditionOperator::Gte).unwrap(); - assert_eq!(json, "\"gte\""); - - let parsed: ConditionOperator = serde_json::from_str("\"not_contains\"").unwrap(); - assert_eq!(parsed, ConditionOperator::NotContains); - } - - // ========================================================================= - // Parsing Helper Tests - // ========================================================================= - - #[test] - fn test_parse_preprocessing_rules_none() { - let result = parse_preprocessing_rules(None); - assert!(result.is_ok()); - assert!(result.unwrap().is_empty()); - } - - #[test] - fn test_parse_preprocessing_rules_empty() { - let result = parse_preprocessing_rules(Some("")); - assert!(result.is_ok()); - assert!(result.unwrap().is_empty()); - - let result = parse_preprocessing_rules(Some(" ")); - assert!(result.is_ok()); - assert!(result.unwrap().is_empty()); - } - - #[test] - fn test_parse_preprocessing_rules_valid() { - let json = r#"[{"pattern": "test", "replacement": ""}]"#; - let result = parse_preprocessing_rules(Some(json)); - assert!(result.is_ok()); - assert_eq!(result.unwrap().len(), 1); - } - - #[test] - fn test_parse_preprocessing_rules_invalid() { - let result = parse_preprocessing_rules(Some("not json")); - assert!(result.is_err()); - assert!( - result - .unwrap_err() - .contains("Failed to parse preprocessing rules") - ); - } - - #[test] - fn test_parse_auto_match_conditions_none() { - let result = parse_auto_match_conditions(None); - assert!(result.is_ok()); - assert!(result.unwrap().is_none()); - } - - #[test] - fn test_parse_auto_match_conditions_empty() { - let result = parse_auto_match_conditions(Some("")); - assert!(result.is_ok()); - assert!(result.unwrap().is_none()); - } - - #[test] - fn test_parse_auto_match_conditions_valid() { - let json = r#"{"mode": "all", "rules": []}"#; - let result = parse_auto_match_conditions(Some(json)); - assert!(result.is_ok()); - let conditions = result.unwrap().unwrap(); - assert_eq!(conditions.mode, ConditionMode::All); - } +//! The canonical home is [`crate::models::preprocessing`] so the db layer +//! can speak these types without depending on services. This module keeps +//! the historical `services::metadata::preprocessing::types::*` path alive +//! for the local processing logic in sibling modules. - #[test] - fn test_parse_auto_match_conditions_invalid() { - let result = parse_auto_match_conditions(Some("not json")); - assert!(result.is_err()); - assert!( - result - .unwrap_err() - .contains("Failed to parse auto-match conditions") - ); - } -} +pub use crate::models::preprocessing::*; diff --git a/src/services/mod.rs b/src/services/mod.rs index 12ff3b88..2b3e7aa8 100644 --- a/src/services/mod.rs +++ b/src/services/mod.rs @@ -1,6 +1,7 @@ pub mod auth_tracking; pub mod book_export_collector; pub mod cleanup_subscriber; +pub mod content_filter; pub mod email; pub mod export_storage; pub mod file_cleanup; @@ -19,6 +20,7 @@ pub mod rate_limiter; pub mod read_progress; pub mod refresh_token; pub mod release; +pub mod scheduler_handle; pub mod series_export_collector; pub mod series_export_writer; pub mod settings; @@ -46,6 +48,8 @@ pub use task_listener::TaskListener; pub use task_metrics::TaskMetricsService; pub use thumbnail::ThumbnailService; -pub use plugin::encryption::CredentialEncryption; +// Historical alias. The canonical location is `crate::utils::credential_encryption`. +#[allow(unused_imports)] +pub use crate::utils::credential_encryption::CredentialEncryption; pub use plugin_file_storage::{PluginCleanupStats, PluginFileStorage, PluginStorageStats}; pub use plugin_metrics::{PluginHealthStatus, PluginMetricsService}; diff --git a/src/services/plugin/handle.rs b/src/services/plugin/handle.rs index 1bb7099c..3c84d135 100644 --- a/src/services/plugin/handle.rs +++ b/src/services/plugin/handle.rs @@ -148,9 +148,9 @@ pub struct PluginHandle { /// Optional database connection for handlers that need DB access /// post-initialization (releases handler, etc.). release_db: Option<DatabaseConnection>, - /// Optional scheduler reference so the releases handler can reconcile + /// Optional scheduler handle so the releases handler can reconcile /// release-source schedules immediately after `releases/register_sources`. - scheduler: Option<Arc<tokio::sync::Mutex<crate::scheduler::Scheduler>>>, + scheduler: Option<crate::services::scheduler_handle::SharedSchedulerReconciler>, } impl PluginHandle { @@ -193,12 +193,12 @@ impl PluginHandle { self } - /// Attach a scheduler reference so the releases reverse-RPC handler can + /// Attach a scheduler handle so the releases reverse-RPC handler can /// trigger a release-source reconcile when the plugin calls /// `releases/register_sources`. Builder-style. pub fn with_scheduler( mut self, - scheduler: Arc<tokio::sync::Mutex<crate::scheduler::Scheduler>>, + scheduler: crate::services::scheduler_handle::SharedSchedulerReconciler, ) -> Self { self.scheduler = Some(scheduler); self diff --git a/src/services/plugin/manager.rs b/src/services/plugin/manager.rs index 178e6353..1c33259d 100644 --- a/src/services/plugin/manager.rs +++ b/src/services/plugin/manager.rs @@ -335,7 +335,7 @@ pub struct PluginManager { /// Optional scheduler handle so the releases reverse-RPC handler can /// trigger a release-source reconcile when a plugin calls /// `releases/register_sources`. - scheduler: Option<Arc<tokio::sync::Mutex<crate::scheduler::Scheduler>>>, + scheduler: Option<crate::services::scheduler_handle::SharedSchedulerReconciler>, } impl PluginManager { @@ -379,7 +379,7 @@ impl PluginManager { /// `releases/register_sources`. Builder-style. pub fn with_scheduler( mut self, - scheduler: Arc<tokio::sync::Mutex<crate::scheduler::Scheduler>>, + scheduler: crate::services::scheduler_handle::SharedSchedulerReconciler, ) -> Self { self.scheduler = Some(scheduler); self diff --git a/src/services/plugin/mod.rs b/src/services/plugin/mod.rs index 40c8d437..d9cf9bc7 100644 --- a/src/services/plugin/mod.rs +++ b/src/services/plugin/mod.rs @@ -68,7 +68,6 @@ //! - [`health`]: Health monitoring and failure tracking //! - [`secrets`]: Secure credential handling with redaction -pub mod encryption; pub mod handle; pub mod health; pub mod library; diff --git a/src/services/plugin/protocol.rs b/src/services/plugin/protocol.rs index ac0dfa2b..77638f43 100644 --- a/src/services/plugin/protocol.rs +++ b/src/services/plugin/protocol.rs @@ -287,422 +287,17 @@ pub mod methods { } // ============================================================================= -// Plugin Manifest Types +// Plugin Manifest Types (re-exported from models::plugin) // ============================================================================= - -/// Plugin manifest returned on initialize -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct PluginManifest { - /// Unique identifier (e.g., "mangaupdates") - pub name: String, - /// Display name for UI (e.g., "MangaUpdates") - pub display_name: String, - /// Semantic version (e.g., "1.0.0") - pub version: String, - /// Description of the plugin - #[serde(default, skip_serializing_if = "Option::is_none")] - pub description: Option<String>, - /// Plugin author - #[serde(default, skip_serializing_if = "Option::is_none")] - pub author: Option<String>, - /// Plugin homepage URL - #[serde(default, skip_serializing_if = "Option::is_none")] - pub homepage: Option<String>, - - /// Protocol version this plugin implements - pub protocol_version: String, - - /// Plugin capabilities - pub capabilities: PluginCapabilities, - - /// Required credentials for this plugin - #[serde(default)] - pub required_credentials: Vec<CredentialField>, - - /// JSON Schema for plugin-specific configuration (admin-facing) - #[serde(default, skip_serializing_if = "Option::is_none")] - pub config_schema: Option<Value>, - - /// Configuration schema for per-user settings (user-facing) - #[serde(default, skip_serializing_if = "Option::is_none")] - pub user_config_schema: Option<Value>, - - /// Plugin type: "system" (admin-only metadata) or "user" (per-user integrations) - #[serde(default)] - pub plugin_type: PluginManifestType, - - /// OAuth 2.0 configuration for user plugins that require external service authentication - #[serde(default, skip_serializing_if = "Option::is_none")] - pub oauth: Option<OAuthConfig>, - - /// User-facing description shown when enabling the plugin - #[serde(default, skip_serializing_if = "Option::is_none")] - pub user_description: Option<String>, - - /// Admin-facing setup instructions (e.g., how to create OAuth app, set client ID) - #[serde(default, skip_serializing_if = "Option::is_none")] - pub admin_setup_instructions: Option<String>, - - /// User-facing setup instructions (e.g., how to connect or get a personal token) - #[serde(default, skip_serializing_if = "Option::is_none")] - pub user_setup_instructions: Option<String>, - - /// URI template for searching on the plugin's website. - /// Use `<title>` as placeholder for the URL-encoded search query. - /// Example: `https://mangabaka.org/search?sort_by=popularity_asc&q=<title>` - #[serde( - default, - skip_serializing_if = "Option::is_none", - rename = "searchURITemplate" - )] - pub search_uri_template: Option<String>, -} - -/// Content types that a metadata provider can support -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)] -#[serde(rename_all = "lowercase")] -pub enum MetadataContentType { - /// Series metadata (manga, comics, etc.) - Series, - /// Book metadata (individual books, ebooks, novels) - Book, -} - -/// Plugin capabilities -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct PluginCapabilities { - /// Content types this plugin can provide metadata for - /// e.g., ["series"] or ["series", "book"] - #[serde(default)] - pub metadata_provider: Vec<MetadataContentType>, - /// Can sync user reading progress (v2) - #[serde(default)] - pub user_read_sync: bool, - /// External ID source used to match sync entries to Codex series. - /// When set, pulled sync entries are matched to series via the - /// `series_external_ids` table using this source string. - /// Uses the `api:<service>` convention, e.g. "api:anilist". - /// Only meaningful when `user_read_sync` is true. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub external_id_source: Option<String>, - /// Can provide personalized recommendations (v2) - #[serde(default)] - pub user_recommendation_provider: bool, - /// Can announce new releases (chapters/volumes) for tracked series. - /// When present, the plugin may invoke the `releases/*` reverse-RPC - /// methods. The capability struct declares the data the plugin needs - /// (aliases, external IDs) so the host can scope its responses. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub release_source: Option<ReleaseSourceCapability>, -} - -/// Release-source capability declaration. -/// -/// Plugins that want to announce releases declare this capability in their -/// manifest. The struct describes both *what* the plugin can announce and -/// *what* it needs from the host. The host uses these fields when filling -/// `releases/list_tracked` responses so plugins only see data they asked for. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ReleaseSourceCapability { - /// Source kinds this plugin exposes (e.g. `["rss-uploader"]`). - #[serde(default)] - pub kinds: Vec<ReleaseSourceKind>, - /// Whether the plugin needs title aliases (set when the plugin matches - /// by title rather than by external ID, e.g. Nyaa). - #[serde(default)] - pub requires_aliases: bool, - /// External-ID sources the plugin needs, e.g. `["mangaupdates"]` or - /// `["mangadex"]`. The host filters `series_external_ids` to these - /// sources when responding to `releases/list_tracked`. - #[serde(default)] - pub requires_external_ids: Vec<String>, - /// Whether the plugin announces chapter-level releases. - #[serde(default)] - pub can_announce_chapters: bool, - /// Whether the plugin announces volume-level releases. - #[serde(default)] - pub can_announce_volumes: bool, -} - -impl Default for ReleaseSourceCapability { - fn default() -> Self { - Self { - kinds: Vec::new(), - requires_aliases: false, - requires_external_ids: Vec::new(), - can_announce_chapters: true, - can_announce_volumes: true, - } - } -} - -/// Kind of release source. Mirrors the `release_sources.kind` column on the -/// host side, but lives here so plugins can declare it without depending on -/// the database schema. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "kebab-case")] -pub enum ReleaseSourceKind { - /// Per-uploader feed (e.g., a Nyaa user RSS feed). - RssUploader, - /// Per-series feed (e.g., MangaUpdates RSS for a single series). - RssSeries, - /// Generic API-driven feed. - ApiFeed, - /// Metadata-derived signal (informational; usually doesn't write the - /// ledger). - MetadataFeed, -} - -impl ReleaseSourceKind { - /// Canonical kebab-case string matching `release_sources.kind` and the - /// serde representation. Used when comparing against string-typed - /// `kind` fields parsed from RPC requests. - pub fn as_str(&self) -> &'static str { - match self { - Self::RssUploader => "rss-uploader", - Self::RssSeries => "rss-series", - Self::ApiFeed => "api-feed", - Self::MetadataFeed => "metadata-feed", - } - } -} - -impl PluginCapabilities { - /// Check if the plugin can provide series metadata - pub fn can_provide_series_metadata(&self) -> bool { - self.metadata_provider - .contains(&MetadataContentType::Series) - } - - /// Check if the plugin can provide book metadata - pub fn can_provide_book_metadata(&self) -> bool { - self.metadata_provider.contains(&MetadataContentType::Book) - } - - /// Whether this plugin declares the `release_source` capability. - pub fn is_release_source(&self) -> bool { - self.release_source.is_some() - } - - /// Infer the plugin type from capabilities. - /// - /// User-facing capabilities (`user_read_sync`, `user_recommendation_provider`) - /// indicate a "user" plugin. Metadata-provider and release-source - /// capabilities indicate a "system" plugin. Returns `None` when - /// capabilities are empty. - pub fn inferred_plugin_type(&self) -> Option<PluginManifestType> { - if self.user_read_sync || self.user_recommendation_provider { - Some(PluginManifestType::User) - } else if !self.metadata_provider.is_empty() || self.release_source.is_some() { - Some(PluginManifestType::System) - } else { - None - } - } -} - -/// Plugin manifest type (declared by the plugin in its manifest) -#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "lowercase")] -pub enum PluginManifestType { - /// System plugin: admin-configured, operates on shared library metadata - #[default] - System, - /// User plugin: per-user integrations (sync, recommendations) - User, -} - -impl std::fmt::Display for PluginManifestType { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::System => write!(f, "system"), - Self::User => write!(f, "user"), - } - } -} - -/// OAuth 2.0 configuration for user plugins -/// -/// Plugins declare their OAuth requirements in the manifest. Codex handles -/// the OAuth flow (authorization URL generation, code exchange, token storage) -/// so plugins never directly interact with the OAuth provider. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct OAuthConfig { - /// OAuth 2.0 authorization endpoint URL - pub authorization_url: String, - /// OAuth 2.0 token endpoint URL - pub token_url: String, - /// Required OAuth scopes - #[serde(default)] - pub scopes: Vec<String>, - /// Whether to use PKCE (Proof Key for Code Exchange) - /// Recommended for public clients; defaults to true - #[serde(default = "default_true")] - pub pkce: bool, - /// Optional user info endpoint URL (to fetch external identity after auth) - #[serde(default, skip_serializing_if = "Option::is_none")] - pub user_info_url: Option<String>, - /// OAuth client ID (can be overridden by admin in plugin config) - #[serde(default, skip_serializing_if = "Option::is_none")] - pub client_id: Option<String>, -} - -fn default_true() -> bool { - true -} - -impl OAuthConfig { - /// Validate that the OAuth config has all required fields - #[allow(dead_code)] // Protocol contract: validation for plugin registration - pub fn validate(&self) -> Result<(), String> { - if self.authorization_url.is_empty() { - return Err("OAuth authorization_url is required".to_string()); - } - if self.token_url.is_empty() { - return Err("OAuth token_url is required".to_string()); - } - // Validate URLs start with https:// (or http:// for local dev) - if !self.authorization_url.starts_with("https://") - && !self.authorization_url.starts_with("http://") - { - return Err(format!( - "Invalid OAuth authorization_url (must start with http:// or https://): {}", - self.authorization_url - )); - } - if !self.token_url.starts_with("https://") && !self.token_url.starts_with("http://") { - return Err(format!( - "Invalid OAuth token_url (must start with http:// or https://): {}", - self.token_url - )); - } - if let Some(ref user_info_url) = self.user_info_url - && !user_info_url.starts_with("https://") - && !user_info_url.starts_with("http://") - { - return Err(format!( - "Invalid OAuth user_info_url (must start with http:// or https://): {}", - user_info_url - )); - } - Ok(()) - } -} - -/// Credential field definition -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct CredentialField { - /// Credential key (e.g., "api_key") - pub key: String, - /// Display label (e.g., "API Key") - pub label: String, - /// Description for the user - #[serde(default, skip_serializing_if = "Option::is_none")] - pub description: Option<String>, - /// Whether this credential is required - #[serde(default)] - pub required: bool, - /// Whether to mask the value in UI - #[serde(default)] - pub sensitive: bool, - /// Input type for UI - #[serde(default)] - pub credential_type: CredentialType, -} - -/// Credential input type -#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "lowercase")] -pub enum CredentialType { - #[default] - String, - Password, - OAuth, -} - -// ============================================================================= -// Plugin Scopes (Server-Side) -// ============================================================================= - -/// Plugin scope defining where it can be invoked (server-side only). -/// -/// Note: Scopes are determined by the server based on plugin capabilities, -/// not declared in the plugin manifest. This enum is used internally by Codex -/// to control where plugins can be invoked. -#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum PluginScope { - // ========================================================================= - // Series Scopes - // ========================================================================= - /// Series detail page dropdown (search + auto-match) - #[serde(rename = "series:detail")] - SeriesDetail, - /// Series list bulk actions (auto-match only) - #[serde(rename = "series:bulk")] - SeriesBulk, - - // ========================================================================= - // Book Scopes - // ========================================================================= - /// Book detail page dropdown (search + auto-match) - #[serde(rename = "book:detail")] - BookDetail, - /// Book list bulk actions (auto-match only) - #[serde(rename = "book:bulk")] - BookBulk, - - // ========================================================================= - // Library Scopes - // ========================================================================= - /// Library dropdown action (auto-match all series/books) - #[serde(rename = "library:detail")] - LibraryDetail, - /// Post-analysis hook (auto-match if forced/changed) - #[serde(rename = "library:scan")] - LibraryScan, -} - -impl PluginScope { - /// Get scopes available for series metadata providers - pub fn series_scopes() -> Vec<Self> { - vec![ - Self::SeriesDetail, - Self::SeriesBulk, - Self::LibraryDetail, - Self::LibraryScan, - ] - } - - /// Get scopes available for book metadata providers - #[allow(dead_code)] // Protocol contract: scope helpers for book metadata plugins - pub fn book_scopes() -> Vec<Self> { - vec![ - Self::BookDetail, - Self::BookBulk, - Self::LibraryDetail, - Self::LibraryScan, - ] - } - - /// Get all scopes (series + book + library) - #[allow(dead_code)] // Protocol contract: scope helpers for multi-content plugins - pub fn all_scopes() -> Vec<Self> { - vec![ - Self::SeriesDetail, - Self::SeriesBulk, - Self::BookDetail, - Self::BookBulk, - Self::LibraryDetail, - Self::LibraryScan, - ] - } -} +// +// These types live in `crate::models::plugin` so the db layer can speak the +// plugin protocol vocabulary without depending on services. The re-exports +// preserve historical paths used throughout the plugin codebase. +#[allow(unused_imports)] +pub use crate::models::plugin::{ + CredentialField, CredentialType, MetadataContentType, OAuthConfig, PluginCapabilities, + PluginManifest, PluginManifestType, PluginScope, ReleaseSourceCapability, ReleaseSourceKind, +}; // ============================================================================= // Metadata Types diff --git a/src/services/plugin/releases_handler.rs b/src/services/plugin/releases_handler.rs index 75c5fc29..56e46c92 100644 --- a/src/services/plugin/releases_handler.rs +++ b/src/services/plugin/releases_handler.rs @@ -11,13 +11,11 @@ //! and validation. use std::collections::HashMap; -use std::sync::Arc; use chrono::{DateTime, Utc}; use sea_orm::DatabaseConnection; use serde::{Deserialize, Serialize}; use serde_json::Value; -use tokio::sync::Mutex; use tracing::{debug, error, info, warn}; use uuid::Uuid; @@ -31,11 +29,11 @@ use crate::db::repositories::{ NewReleaseSource, ReleaseLedgerRepository, ReleaseSourceRepository, SeriesAliasRepository, SeriesExternalIdRepository, SeriesRepository, SeriesTrackingRepository, TrackingUpdate, }; -use crate::scheduler::Scheduler; use crate::services::release::auto_ignore::{is_outside_tracking_scope, should_auto_ignore}; use crate::services::release::candidate::ReleaseCandidate; use crate::services::release::languages::{includes, resolve_for_series}; use crate::services::release::matcher::{evaluate, resolve_threshold}; +use crate::services::scheduler_handle::SharedSchedulerReconciler; /// Default page size for `releases/list_tracked` when the caller doesn't /// specify one. Bounded to keep the response small on first load. @@ -63,9 +61,9 @@ pub struct ReleasesRequestHandler { /// to scope `releases/list_tracked` responses to what the plugin asked /// for. capability: ReleaseSourceCapability, - /// Optional scheduler reference used by `releases/register_sources` to + /// Optional scheduler handle used by `releases/register_sources` to /// reconcile schedules immediately after the source set changes. - scheduler: Option<Arc<Mutex<Scheduler>>>, + scheduler: Option<SharedSchedulerReconciler>, } impl ReleasesRequestHandler { @@ -82,9 +80,9 @@ impl ReleasesRequestHandler { } } - /// Attach a scheduler reference so `releases/register_sources` reconciles + /// Attach a scheduler handle so `releases/register_sources` reconciles /// schedules without waiting for a server restart. Builder-style. - pub fn with_scheduler(mut self, scheduler: Arc<Mutex<Scheduler>>) -> Self { + pub fn with_scheduler(mut self, scheduler: SharedSchedulerReconciler) -> Self { self.scheduler = Some(scheduler); self } @@ -922,11 +920,10 @@ impl ReleasesRequestHandler { // Reconcile schedules. Best-effort — log failures but don't fail the // RPC, since the rows are already persisted and the next scheduler // start (or HTTP-driven reconcile) will catch up. - if let Some(ref scheduler) = self.scheduler { - let mut guard = scheduler.lock().await; - if let Err(e) = guard.reconcile_release_sources().await { - warn!(error = %e, "scheduler reconcile after register_sources failed"); - } + if let Some(ref scheduler) = self.scheduler + && let Err(e) = scheduler.reconcile_release_sources().await + { + warn!(error = %e, "scheduler reconcile after register_sources failed"); } let response = RegisterSourcesResponse { @@ -1219,6 +1216,7 @@ mod tests { use crate::services::plugin::protocol::ReleaseSourceKind; use crate::services::release::candidate::{NumericSpan, SeriesMatch}; use serde_json::json; + use std::sync::Arc; fn make_capability( requires_aliases: bool, diff --git a/src/services/release/auto_ignore.rs b/src/services/release/auto_ignore.rs index 85b3b79a..7780b3e4 100644 --- a/src/services/release/auto_ignore.rs +++ b/src/services/release/auto_ignore.rs @@ -19,26 +19,8 @@ //! an owned `(Some(3), None)`; a release for "Ch 12" matches an owned //! `(_, Some(12))` regardless of volume. -use crate::services::release::candidate::NumericSpan; - -/// Per-series ownership signature consumed by [`should_auto_ignore`]. -#[derive(Debug, Default, Clone)] -pub struct OwnedReleaseKeys { - /// `(volume, chapter)` pairs from book metadata, after filtering out - /// rows with both fields null. - /// - /// - `(Some(v), None)` — whole volume `v` owned (no specific chapter). - /// - `(Some(v), Some(c))` — chapter `c` of volume `v` owned. - /// - `(None, Some(c))` — chapter `c` owned, volume unknown. - pub keys: Vec<(Option<i32>, Option<f64>)>, - /// `true` if at least one book in the series carries volume metadata. - /// When `false`, we fall back to [`Self::volumes_owned_count`]. - pub has_any_volume_metadata: bool, - /// Count of "complete-volume" books (volume IS NOT NULL AND chapter - /// IS NULL). Only consulted in the count-fallback branch when - /// [`Self::has_any_volume_metadata`] is `false`. - pub volumes_owned_count: i64, -} +use crate::models::release::NumericSpan; +pub use crate::models::release::OwnedReleaseKeys; /// True when the user owns *every* item the release covers. /// diff --git a/src/services/release/candidate.rs b/src/services/release/candidate.rs index bebb94d3..9a339336 100644 --- a/src/services/release/candidate.rs +++ b/src/services/release/candidate.rs @@ -8,82 +8,10 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use uuid::Uuid; -/// Inclusive numeric span. Single values are encoded as `start == end` -/// (e.g. `NumericSpan { start: 5.0, end: 5.0 }`). -/// -/// A release candidate carries one [`Vec<NumericSpan>`] per axis (volumes -/// and chapters). Disjoint coverage (`v01-04 + v06-09`) is preserved as -/// multiple spans; the host's auto-ignore walks every value in every span -/// before deciding the user owns the release. -#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct NumericSpan { - pub start: f64, - pub end: f64, -} - -/// Normalize a span list: -/// 1. Swap any span where `start > end` (defensive against buggy plugins). -/// 2. Sort ascending by `start`, then `end`. -/// 3. Merge overlapping spans (touching counts as overlap). -/// -/// Mirrors the parser-side `normalizeSpans` in [`plugins/release-nyaa`] so -/// host and plugin agree on the canonical shape stored in the ledger. -/// Returns `None` when the input is `Some(empty)` so callers can collapse -/// "I parsed an empty list" into "no info" before persistence. -pub fn normalize_spans(spans: Option<Vec<NumericSpan>>) -> Option<Vec<NumericSpan>> { - let raw = spans?; - if raw.is_empty() { - return None; - } - let mut fixed: Vec<NumericSpan> = raw - .into_iter() - .map(|s| { - if s.start <= s.end { - s - } else { - NumericSpan { - start: s.end, - end: s.start, - } - } - }) - .collect(); - fixed.sort_by(|a, b| { - a.start - .partial_cmp(&b.start) - .unwrap_or(std::cmp::Ordering::Equal) - .then_with(|| { - a.end - .partial_cmp(&b.end) - .unwrap_or(std::cmp::Ordering::Equal) - }) - }); - let mut out: Vec<NumericSpan> = Vec::with_capacity(fixed.len()); - for s in fixed { - match out.last_mut() { - Some(last) if s.start <= last.end => { - if s.end > last.end { - last.end = s.end; - } - } - _ => out.push(s), - } - } - Some(out) -} - -/// Highest end-value across every span. `None` for an empty / missing list. -/// Used to derive the primary scalar (`chapter` / `volume`) the SQL ORDER BY -/// clauses still rely on. -pub fn primary_value(spans: Option<&Vec<NumericSpan>>) -> Option<f64> { - let list = spans?; - list.iter().map(|s| s.end).fold(None, |acc, v| match acc { - None => Some(v), - Some(cur) if v > cur => Some(v), - other => other, - }) -} +// `NumericSpan` and the span helpers live in `crate::models::release` so the +// db layer can consume them without importing services. +#[allow(unused_imports)] +pub use crate::models::release::{NumericSpan, normalize_spans, primary_value}; /// A release candidate emitted by a `release_source` plugin. /// diff --git a/src/services/scheduler_handle.rs b/src/services/scheduler_handle.rs new file mode 100644 index 00000000..d45ad052 --- /dev/null +++ b/src/services/scheduler_handle.rs @@ -0,0 +1,29 @@ +//! Trait abstraction for the cron scheduler. +//! +//! `services` needs a way to ask the scheduler to recompute its release-source +//! jobs after a write to `release_sources`, but the concrete scheduler lives +//! above `services` in the layering. This trait inverts that dependency: +//! `services` depends on `SchedulerReconciler`, and the `scheduler` module +//! provides the implementation. + +use std::sync::Arc; + +use anyhow::Result; +use futures::future::BoxFuture; + +/// Anything the services layer can ask the scheduler to do. +/// +/// The only operation services needs today is "reconcile release-source +/// schedules"; if that grows we'll add methods here rather than handing out +/// the full `Scheduler` type. The method returns a `BoxFuture` so the trait +/// stays object-safe without dragging in `async-trait`. +pub trait SchedulerReconciler: Send + Sync { + /// Reload the release-source poll schedule from the database. Called + /// after writes to `release_sources` (e.g. when a plugin re-registers + /// its sources) so the scheduler picks up enable/disable + cron changes + /// without a restart. + fn reconcile_release_sources(&self) -> BoxFuture<'_, Result<()>>; +} + +/// Type alias used everywhere services-side code holds the handle. +pub type SharedSchedulerReconciler = Arc<dyn SchedulerReconciler>; diff --git a/src/services/series_export_collector.rs b/src/services/series_export_collector.rs index f8cb9ea2..8108785e 100644 --- a/src/services/series_export_collector.rs +++ b/src/services/series_export_collector.rs @@ -11,13 +11,13 @@ use std::collections::HashMap; use std::fmt; use uuid::Uuid; -use crate::api::extractors::content_filter::ContentFilter; use crate::db::entities::series; use crate::db::repositories::{ AlternateTitleRepository, BookRepository, ExternalRatingRepository, GenreRepository, LibraryRepository, SeriesMetadataRepository, SeriesRepository, TagRepository, UserSeriesRatingRepository, }; +use crate::services::content_filter::ContentFilter; // ============================================================================= // ExportField enum diff --git a/src/tasks/error.rs b/src/tasks/error.rs index 4b4985d9..c40f776c 100644 --- a/src/tasks/error.rs +++ b/src/tasks/error.rs @@ -14,11 +14,11 @@ use crate::services::plugin::PluginManagerError; -/// Default retry delay in seconds for rate-limited tasks -pub const DEFAULT_RATE_LIMIT_RETRY_SECONDS: u64 = 30; - -/// Default maximum number of rate limit reschedules before marking as failed -pub const DEFAULT_MAX_RESCHEDULES: i32 = 10; +// Re-exported from `crate::models::task` so existing call sites work and the +// canonical constants live in the shared `models` layer (avoids db -> tasks +// imports for what is really a value type). +#[allow(unused_imports)] +pub use crate::models::task::{DEFAULT_MAX_RESCHEDULES, DEFAULT_RATE_LIMIT_RETRY_SECONDS}; /// Trait for errors that represent rate limiting /// diff --git a/src/tasks/types.rs b/src/tasks/types.rs index 25cd0c6d..62bfc714 100644 --- a/src/tasks/types.rs +++ b/src/tasks/types.rs @@ -1,1487 +1,8 @@ -//! Task types supported by the distributed task queue +//! Re-export of task value types. //! -//! TODO: Remove allow(dead_code) once all task features are fully integrated +//! The canonical home is [`crate::models::task`]. This module keeps the +//! `crate::tasks::types::*` path working for tests and downstream code while +//! the data shapes live in `models` so non-tasks layers can speak them +//! without depending on the tasks layer. -#![allow(dead_code)] - -use serde::{Deserialize, Serialize}; -use utoipa::ToSchema; -use uuid::Uuid; - -/// Task types supported by the distributed task queue -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] -#[serde(tag = "type", rename_all = "snake_case")] -pub enum TaskType { - /// Scan a library for new/changed books - ScanLibrary { - #[serde(rename = "libraryId")] - library_id: Uuid, - #[serde(default = "default_mode")] - mode: String, // "normal" or "deep" - }, - - /// Analyze a single book's metadata - AnalyzeBook { - #[serde(rename = "bookId")] - book_id: Uuid, - #[serde(default)] - force: bool, - }, - - /// Analyze all books in a series (always forces re-analysis) - AnalyzeSeries { - #[serde(rename = "seriesId")] - series_id: Uuid, - }, - - /// Purge soft-deleted books from a library - PurgeDeleted { - #[serde(rename = "libraryId")] - library_id: Uuid, - }, - - /// Refresh metadata from external source - RefreshMetadata { - #[serde(rename = "bookId")] - book_id: Uuid, - source: String, // "comicvine", "openlibrary", etc. - }, - - /// Scheduled per-job metadata refresh. - /// - /// Loads the [`library_jobs`] row by `job_id`, decodes its config (single - /// provider + field groups + safety options), walks the library's series, - /// and refreshes metadata via the existing `MetadataApplier`. - RefreshLibraryMetadata { - #[serde(rename = "jobId")] - job_id: Uuid, - }, - - /// Generate thumbnails for books in a scope (library, series, specific books, or all) - /// This is a fan-out task that enqueues individual GenerateThumbnail tasks - GenerateThumbnails { - #[serde(rename = "libraryId")] - library_id: Option<Uuid>, // If set, only books in this library - #[serde(rename = "seriesId")] - series_id: Option<Uuid>, // If set, only books in this series (takes precedence over library_id) - #[serde(rename = "seriesIds", default)] - series_ids: Option<Vec<Uuid>>, // If set, only books in these specific series (takes precedence over series_id and library_id) - #[serde(rename = "bookIds", default)] - book_ids: Option<Vec<Uuid>>, // If set, only these specific books (takes precedence over all other scopes) - #[serde(default)] - force: bool, // If true, regenerate all thumbnails; if false, only missing ones - }, - - /// Generate thumbnail for a single book - GenerateThumbnail { - #[serde(rename = "bookId")] - book_id: Uuid, - #[serde(default)] - force: bool, // If true, regenerate even if thumbnail exists - }, - - /// Generate thumbnail for a series (from first book's cover) - GenerateSeriesThumbnail { - #[serde(rename = "seriesId")] - series_id: Uuid, - #[serde(default)] - force: bool, // If true, regenerate even if thumbnail exists - }, - - /// Generate thumbnails for series in a scope (library, specific series, or all) - /// This is a fan-out task that enqueues individual GenerateSeriesThumbnail tasks - GenerateSeriesThumbnails { - #[serde(rename = "libraryId")] - library_id: Option<Uuid>, // If set, only series in this library - #[serde(rename = "seriesIds", default)] - series_ids: Option<Vec<Uuid>>, // If set, only these specific series (takes precedence over library_id) - #[serde(default)] - force: bool, // If true, regenerate all thumbnails; if false, only missing ones - }, - - /// Find and catalog duplicate books across all libraries - FindDuplicates, - - /// Clean up files for a deleted book (thumbnail + cover references) - CleanupBookFiles { - #[serde(rename = "bookId")] - book_id: Uuid, - /// Optional thumbnail path (if known at deletion time) - #[serde(rename = "thumbnailPath", default)] - thumbnail_path: Option<String>, - /// Optional series_id to invalidate series thumbnail cache - #[serde(rename = "seriesId", default)] - series_id: Option<Uuid>, - }, - - /// Clean up files for a deleted series (cover files) - CleanupSeriesFiles { - #[serde(rename = "seriesId")] - series_id: Uuid, - }, - - /// Scan filesystem for orphaned files and delete them - CleanupOrphanedFiles, - - /// Clean up old pages from the PDF page cache - CleanupPdfCache, - - /// Auto-match metadata for a series using a plugin - PluginAutoMatch { - #[serde(rename = "seriesId")] - series_id: Uuid, - #[serde(rename = "pluginId")] - plugin_id: Uuid, - /// Source scope that triggered this task (for tracking) - #[serde(rename = "sourceScope", default)] - source_scope: Option<String>, // "series:detail", "series:bulk", "library:detail", "library:scan" - }, - - /// Reprocess a single series title using library preprocessing rules - ReprocessSeriesTitle { - #[serde(rename = "seriesId")] - series_id: Uuid, - }, - - /// Reprocess series titles in a scope (library, bulk selection, or specific series) - /// This is a fan-out task that enqueues individual ReprocessSeriesTitle tasks - ReprocessSeriesTitles { - #[serde(rename = "libraryId")] - library_id: Option<Uuid>, // If set, process all series in this library - #[serde(rename = "seriesIds", default)] - series_ids: Option<Vec<Uuid>>, // If set, process only these specific series (bulk selection) - }, - - /// Renumber books in a single series using the library's number strategy - RenumberSeries { - #[serde(rename = "seriesId")] - series_id: Uuid, - }, - - /// Renumber books in multiple series (fan-out task) - /// This is a fan-out task that enqueues individual RenumberSeries tasks - RenumberSeriesBatch { - #[serde(rename = "seriesIds", default)] - series_ids: Option<Vec<Uuid>>, - }, - - /// Clean up expired plugin storage data across all user plugins - CleanupPluginData, - - /// Clean up expired series exports (files + DB records) - CleanupSeriesExports, - - /// Clean up expired and old-revoked refresh tokens. - /// - /// Deletes any `refresh_tokens` row whose `expires_at` is in the past, plus - /// rows that were revoked more than 30 days ago. Idempotent. - CleanupRefreshTokens, - - /// Sync user plugin data with external service - UserPluginSync { - #[serde(rename = "pluginId")] - plugin_id: Uuid, - #[serde(rename = "userId")] - user_id: Uuid, - }, - - /// Refresh recommendations from a user plugin - UserPluginRecommendations { - #[serde(rename = "pluginId")] - plugin_id: Uuid, - #[serde(rename = "userId")] - user_id: Uuid, - }, - - /// Export series data to a JSON or CSV file - ExportSeries { - #[serde(rename = "exportId")] - export_id: Uuid, - #[serde(rename = "userId")] - user_id: Uuid, - }, - - /// Notify a plugin that a recommendation was dismissed - UserPluginRecommendationDismiss { - #[serde(rename = "pluginId")] - plugin_id: Uuid, - #[serde(rename = "userId")] - user_id: Uuid, - #[serde(rename = "externalId")] - external_id: String, - #[serde(default)] - reason: Option<String>, - }, - - /// Backfill release-tracking aliases from existing series metadata. - /// - /// Walks series in scope, harvests the canonical title plus alternate titles - /// from `series_metadata` and `series_alternate_titles`, and seeds them as - /// `metadata`-source aliases in `series_aliases`. Idempotent — re-runs do - /// not create duplicates. Does NOT enable tracking; that stays explicit. - BackfillTrackingFromMetadata { - /// If set, scope to this library; otherwise all series. - #[serde(rename = "libraryId", default)] - library_id: Option<Uuid>, - /// If set, scope to these specific series (takes precedence over library_id). - #[serde(rename = "seriesIds", default)] - series_ids: Option<Vec<Uuid>>, - }, - - /// Poll a single `release_sources` row for new releases. - /// - /// Resolves the source's owning plugin, calls `releases/poll` over the - /// existing plugin host, runs returned candidates through the matcher + - /// threshold, and writes accepted candidates to the ledger. On success - /// updates `last_polled_at` (and optionally `etag`); on failure records - /// `last_error`. Idempotent: ledger writes dedup on - /// `(source_id, external_release_id)` and `info_hash`. - PollReleaseSource { - #[serde(rename = "sourceId")] - source_id: Uuid, - }, - - /// Bulk-toggle `series_tracking.tracked` across many series. - /// - /// On `tracked: true`, runs `seed_tracking_for_series` per series before - /// flipping the flag (same order as the per-series PATCH and the legacy - /// sync bulk endpoint). On `tracked: false`, only flips the flag — - /// aliases and other config are preserved so re-tracking is non- - /// destructive. Per-series `SeriesUpdated` events are emitted from the - /// handler so the UI updates live as the task processes each series. - BulkTrackForReleases { - #[serde(rename = "seriesIds")] - series_ids: Vec<Uuid>, - tracked: bool, - }, -} - -fn default_mode() -> String { - "normal".to_string() -} - -impl TaskType { - /// Returns the default priority for this task type. - /// - /// Higher values = more urgent. Uses large gaps for future insertions. - /// Categories: - /// 1000-900: Scanning (library discovery, post-scan cleanup) - /// 800-750: Analysis (book/series analysis, title reprocessing) - /// 600-570: Thumbnails (single and batch generation) - /// 400-380: Metadata (deduplication, external lookups, plugin matching) - /// 200-180: Plugins (user-facing plugin operations) - /// 100: Cleanup (low-priority maintenance) - pub fn default_priority(&self) -> i32 { - match self { - // Scanning - TaskType::ScanLibrary { .. } => 1000, - TaskType::PurgeDeleted { .. } => 900, - // Analysis - TaskType::AnalyzeBook { .. } => 800, - TaskType::AnalyzeSeries { .. } => 790, - TaskType::ReprocessSeriesTitle { .. } => 780, - TaskType::ReprocessSeriesTitles { .. } => 770, - TaskType::RenumberSeries { .. } => 760, - TaskType::RenumberSeriesBatch { .. } => 750, - // Thumbnails - TaskType::GenerateThumbnail { .. } => 600, - TaskType::GenerateSeriesThumbnail { .. } => 590, - TaskType::GenerateThumbnails { .. } => 580, - TaskType::GenerateSeriesThumbnails { .. } => 570, - // Metadata - TaskType::FindDuplicates => 400, - TaskType::RefreshMetadata { .. } => 390, - TaskType::RefreshLibraryMetadata { .. } => 385, - TaskType::PluginAutoMatch { .. } => 380, - // Export - TaskType::ExportSeries { .. } => 450, - // Plugins - TaskType::UserPluginRecommendationDismiss { .. } => 200, - TaskType::UserPluginSync { .. } => 190, - TaskType::UserPluginRecommendations { .. } => 180, - // Release tracking maintenance - TaskType::BackfillTrackingFromMetadata { .. } => 150, - // User-initiated bulk track/untrack: above the maintenance - // backfill but below scheduled release polling. - TaskType::BulkTrackForReleases { .. } => 155, - // Release polling: scheduled background discovery - TaskType::PollReleaseSource { .. } => 170, - // Cleanup - TaskType::CleanupBookFiles { .. } - | TaskType::CleanupSeriesFiles { .. } - | TaskType::CleanupOrphanedFiles - | TaskType::CleanupPdfCache - | TaskType::CleanupPluginData - | TaskType::CleanupSeriesExports - | TaskType::CleanupRefreshTokens => 100, - } - } - - /// Extract task type string for database storage - pub fn type_string(&self) -> &'static str { - match self { - TaskType::ScanLibrary { .. } => "scan_library", - TaskType::AnalyzeBook { .. } => "analyze_book", - TaskType::AnalyzeSeries { .. } => "analyze_series", - TaskType::PurgeDeleted { .. } => "purge_deleted", - TaskType::RefreshMetadata { .. } => "refresh_metadata", - TaskType::RefreshLibraryMetadata { .. } => "refresh_library_metadata", - TaskType::GenerateThumbnails { .. } => "generate_thumbnails", - TaskType::GenerateThumbnail { .. } => "generate_thumbnail", - TaskType::GenerateSeriesThumbnail { .. } => "generate_series_thumbnail", - TaskType::GenerateSeriesThumbnails { .. } => "generate_series_thumbnails", - TaskType::FindDuplicates => "find_duplicates", - TaskType::CleanupBookFiles { .. } => "cleanup_book_files", - TaskType::CleanupSeriesFiles { .. } => "cleanup_series_files", - TaskType::CleanupOrphanedFiles => "cleanup_orphaned_files", - TaskType::CleanupPdfCache => "cleanup_pdf_cache", - TaskType::PluginAutoMatch { .. } => "plugin_auto_match", - TaskType::ReprocessSeriesTitle { .. } => "reprocess_series_title", - TaskType::ReprocessSeriesTitles { .. } => "reprocess_series_titles", - TaskType::RenumberSeries { .. } => "renumber_series", - TaskType::RenumberSeriesBatch { .. } => "renumber_series_batch", - TaskType::CleanupPluginData => "cleanup_plugin_data", - TaskType::CleanupSeriesExports => "cleanup_series_exports", - TaskType::CleanupRefreshTokens => "cleanup_refresh_tokens", - TaskType::ExportSeries { .. } => "export_series", - TaskType::UserPluginSync { .. } => "user_plugin_sync", - TaskType::UserPluginRecommendations { .. } => "user_plugin_recommendations", - TaskType::UserPluginRecommendationDismiss { .. } => { - "user_plugin_recommendation_dismiss" - } - TaskType::BackfillTrackingFromMetadata { .. } => "backfill_tracking_from_metadata", - TaskType::PollReleaseSource { .. } => "poll_release_source", - TaskType::BulkTrackForReleases { .. } => "bulk_track_for_releases", - } - } - - /// Extract library_id if present. - /// - /// `RefreshLibraryMetadata` carries `job_id` rather than `library_id`; the - /// library is resolved at run time from the job row. The library scope is - /// reflected by `enqueue_filter_library_id` on enqueue; this helper - /// returns `None` for that variant. - pub fn library_id(&self) -> Option<Uuid> { - match self { - TaskType::ScanLibrary { library_id, .. } => Some(*library_id), - TaskType::PurgeDeleted { library_id } => Some(*library_id), - TaskType::GenerateThumbnails { library_id, .. } => *library_id, - TaskType::GenerateSeriesThumbnails { library_id, .. } => *library_id, - TaskType::ReprocessSeriesTitles { library_id, .. } => *library_id, - TaskType::BackfillTrackingFromMetadata { library_id, .. } => *library_id, - _ => None, - } - } - - /// Extract the library job ID for tasks scoped to a single - /// [`library_jobs`] row, if any. - pub fn job_id(&self) -> Option<Uuid> { - match self { - TaskType::RefreshLibraryMetadata { job_id } => Some(*job_id), - _ => None, - } - } - - /// Get task-specific parameters as JSON - pub fn params(&self) -> serde_json::Value { - match self { - TaskType::ScanLibrary { mode, .. } => { - serde_json::json!({ "mode": mode }) - } - TaskType::AnalyzeBook { force, .. } => { - serde_json::json!({ "force": force }) - } - TaskType::AnalyzeSeries { .. } => { - serde_json::json!({}) - } - TaskType::RefreshMetadata { source, .. } => { - serde_json::json!({ "source": source }) - } - TaskType::RefreshLibraryMetadata { job_id } => { - // job_id is stored in params (no FK column on tasks). - // The handler resolves the library from the job row at run time. - serde_json::json!({ "job_id": job_id }) - } - TaskType::GenerateThumbnails { - force, - book_ids, - series_ids, - .. - } => { - serde_json::json!({ "force": force, "book_ids": book_ids, "series_ids": series_ids }) - } - TaskType::GenerateThumbnail { force, .. } => { - serde_json::json!({ "force": force }) - } - TaskType::GenerateSeriesThumbnail { force, .. } => { - serde_json::json!({ "force": force }) - } - TaskType::GenerateSeriesThumbnails { - force, series_ids, .. - } => { - serde_json::json!({ "force": force, "series_ids": series_ids }) - } - TaskType::CleanupBookFiles { - book_id, - thumbnail_path, - series_id, - } => { - // Store book_id in params since the FK column can't reference deleted books - serde_json::json!({ "book_id": book_id, "thumbnail_path": thumbnail_path, "series_id": series_id }) - } - TaskType::CleanupSeriesFiles { series_id } => { - // Store series_id in params since the FK column can't reference deleted series - serde_json::json!({ "series_id": series_id }) - } - TaskType::PluginAutoMatch { - plugin_id, - source_scope, - .. - } => { - serde_json::json!({ "plugin_id": plugin_id, "source_scope": source_scope }) - } - TaskType::ReprocessSeriesTitles { series_ids, .. } => { - serde_json::json!({ "series_ids": series_ids }) - } - TaskType::RenumberSeriesBatch { series_ids } => { - serde_json::json!({ "series_ids": series_ids }) - } - TaskType::UserPluginSync { plugin_id, user_id } => { - serde_json::json!({ "plugin_id": plugin_id, "user_id": user_id }) - } - TaskType::UserPluginRecommendations { plugin_id, user_id } => { - serde_json::json!({ "plugin_id": plugin_id, "user_id": user_id }) - } - TaskType::ExportSeries { export_id, user_id } => { - serde_json::json!({ "export_id": export_id, "user_id": user_id }) - } - TaskType::UserPluginRecommendationDismiss { - plugin_id, - user_id, - external_id, - reason, - } => { - serde_json::json!({ - "plugin_id": plugin_id, - "user_id": user_id, - "external_id": external_id, - "reason": reason, - }) - } - TaskType::BackfillTrackingFromMetadata { series_ids, .. } => { - serde_json::json!({ "series_ids": series_ids }) - } - TaskType::PollReleaseSource { source_id } => { - serde_json::json!({ "source_id": source_id }) - } - TaskType::BulkTrackForReleases { - series_ids, - tracked, - } => { - serde_json::json!({ "series_ids": series_ids, "tracked": tracked }) - } - _ => serde_json::json!({}), - } - } - - /// Extract series_id if present - /// Note: CleanupSeriesFiles stores series_id in params, not as FK (entity may be deleted) - pub fn series_id(&self) -> Option<Uuid> { - match self { - TaskType::AnalyzeSeries { series_id, .. } => Some(*series_id), - TaskType::GenerateThumbnails { series_id, .. } => *series_id, - TaskType::GenerateSeriesThumbnail { series_id, .. } => Some(*series_id), - TaskType::PluginAutoMatch { series_id, .. } => Some(*series_id), - TaskType::ReprocessSeriesTitle { series_id } => Some(*series_id), - TaskType::RenumberSeries { series_id } => Some(*series_id), - // CleanupSeriesFiles intentionally NOT included - series_id is stored in params - // because the series may already be deleted when the task runs - _ => None, - } - } - - /// Extract book_id if present - /// Note: CleanupBookFiles stores book_id in params, not as FK (entity may be deleted) - pub fn book_id(&self) -> Option<Uuid> { - match self { - TaskType::AnalyzeBook { book_id, .. } => Some(*book_id), - TaskType::RefreshMetadata { book_id, .. } => Some(*book_id), - TaskType::GenerateThumbnail { book_id, .. } => Some(*book_id), - // CleanupBookFiles intentionally NOT included - book_id is stored in params - // because the book is already deleted when the cleanup task runs - _ => None, - } - } - - /// JSON-param key/value pair to use as a dedup discriminator for task - /// types whose identity lives in `params` rather than in FK columns. - /// - /// Returning `Some((key, value))` tells the dedup path in - /// `TaskRepository::find_existing_task` to additionally filter by - /// `params->>key = value`. Without this, two `poll_release_source` tasks - /// for *different* `source_id`s would falsely collide because they share - /// the same `task_type` and have no FK columns set, causing the second - /// "Poll now" click to be silently coalesced onto the first source's - /// in-flight poll. - /// - /// `key` must be a simple identifier (alphanumeric + underscore) since - /// SQLite splices it into a JSON path string. - pub fn dedup_params(&self) -> Option<(&'static str, String)> { - match self { - TaskType::PollReleaseSource { source_id } => Some(("source_id", source_id.to_string())), - _ => None, - } - } - - /// Extract all fields needed for database insertion - /// Returns: (type_string, library_id, series_id, book_id, params) - pub fn extract_fields( - &self, - ) -> ( - &'static str, - Option<Uuid>, - Option<Uuid>, - Option<Uuid>, - Option<serde_json::Value>, - ) { - let type_str = self.type_string(); - let library_id = self.library_id(); - let series_id = self.series_id(); - let book_id = self.book_id(); - let params = self.params(); - - let params_value = if params.is_null() || params.as_object().is_some_and(|o| o.is_empty()) { - None - } else { - Some(params) - }; - - (type_str, library_id, series_id, book_id, params_value) - } -} - -/// Task execution result -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct TaskResult { - pub success: bool, - pub message: Option<String>, - pub data: Option<serde_json::Value>, -} - -impl TaskResult { - pub fn success(message: impl Into<String>) -> Self { - Self { - success: true, - message: Some(message.into()), - data: None, - } - } - - pub fn success_with_data(message: impl Into<String>, data: serde_json::Value) -> Self { - Self { - success: true, - message: Some(message.into()), - data: Some(data), - } - } - - pub fn failure(message: impl Into<String>) -> Self { - Self { - success: false, - message: Some(message.into()), - data: None, - } - } -} - -/// Task queue statistics -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct TaskStats { - /// Total counts across all task types - pub pending: u64, - pub processing: u64, - pub completed: u64, - pub failed: u64, - pub stale: u64, - pub total: u64, - /// Breakdown by task type and status - pub by_type: std::collections::HashMap<String, TaskTypeStats>, -} - -/// Statistics for a specific task type -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct TaskTypeStats { - pub pending: u64, - pub processing: u64, - pub completed: u64, - pub failed: u64, - pub stale: u64, - pub total: u64, -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_task_type_extraction() { - let library_id = Uuid::new_v4(); - let task = TaskType::ScanLibrary { - library_id, - mode: "deep".to_string(), - }; - - assert_eq!(task.type_string(), "scan_library"); - - let (type_str, lib_id, series_id, book_id, params) = task.extract_fields(); - assert_eq!(type_str, "scan_library"); - assert_eq!(lib_id, Some(library_id)); - assert_eq!(series_id, None); - assert_eq!(book_id, None); - assert!(params.is_some()); - } - - #[test] - fn test_analyze_book_extraction() { - let book_id = Uuid::new_v4(); - let task = TaskType::AnalyzeBook { - book_id, - force: false, - }; - - assert_eq!(task.type_string(), "analyze_book"); - - let (_, lib_id, series_id, extracted_book_id, params) = task.extract_fields(); - assert_eq!(lib_id, None); - assert_eq!(series_id, None); - assert_eq!(extracted_book_id, Some(book_id)); - assert!(params.is_some()); - } - - #[test] - fn test_task_result_success() { - let result = TaskResult::success("Task completed"); - assert!(result.success); - assert_eq!(result.message, Some("Task completed".to_string())); - assert!(result.data.is_none()); - } - - #[test] - fn test_task_result_failure() { - let result = TaskResult::failure("Task failed"); - assert!(!result.success); - assert_eq!(result.message, Some("Task failed".to_string())); - } - - #[test] - fn test_task_result_with_data() { - use serde_json::json; - let data = json!({"count": 42}); - let result = TaskResult::success_with_data("Done", data.clone()); - assert!(result.success); - assert_eq!(result.data, Some(data)); - } - - #[test] - fn test_task_stats_total() { - use std::collections::HashMap; - - let stats = TaskStats { - pending: 5, - processing: 3, - completed: 10, - failed: 2, - stale: 1, - total: 21, - by_type: HashMap::new(), - }; - assert_eq!(stats.total, 21); - assert_eq!( - stats.pending + stats.processing + stats.completed + stats.failed, - 20 - ); - } - - #[test] - fn test_generate_thumbnails_extraction() { - let library_id = Uuid::new_v4(); - let series_id = Uuid::new_v4(); - - // Library scope - let task = TaskType::GenerateThumbnails { - library_id: Some(library_id), - series_id: None, - series_ids: None, - book_ids: None, - force: false, - }; - assert_eq!(task.type_string(), "generate_thumbnails"); - assert_eq!(task.library_id(), Some(library_id)); - assert_eq!(task.series_id(), None); - assert_eq!(task.book_id(), None); - - let params = task.params(); - assert_eq!(params["force"], false); - - // Series scope - let task = TaskType::GenerateThumbnails { - library_id: None, - series_id: Some(series_id), - series_ids: None, - book_ids: None, - force: true, - }; - assert_eq!(task.library_id(), None); - assert_eq!(task.series_id(), Some(series_id)); - - let params = task.params(); - assert_eq!(params["force"], true); - - // All scope - let task = TaskType::GenerateThumbnails { - library_id: None, - series_id: None, - series_ids: None, - book_ids: None, - force: false, - }; - assert_eq!(task.library_id(), None); - assert_eq!(task.series_id(), None); - } - - #[test] - fn test_generate_thumbnail_extraction() { - let book_id = Uuid::new_v4(); - - let task = TaskType::GenerateThumbnail { - book_id, - force: true, - }; - - assert_eq!(task.type_string(), "generate_thumbnail"); - assert_eq!(task.library_id(), None); - assert_eq!(task.series_id(), None); - assert_eq!(task.book_id(), Some(book_id)); - - let params = task.params(); - assert_eq!(params["force"], true); - - // Test with force=false - let task = TaskType::GenerateThumbnail { - book_id, - force: false, - }; - let params = task.params(); - assert_eq!(params["force"], false); - } - - #[test] - fn test_generate_thumbnail_extract_fields() { - let book_id = Uuid::new_v4(); - - let task = TaskType::GenerateThumbnail { - book_id, - force: true, - }; - - let (type_str, lib_id, series_id, extracted_book_id, params) = task.extract_fields(); - assert_eq!(type_str, "generate_thumbnail"); - assert_eq!(lib_id, None); - assert_eq!(series_id, None); - assert_eq!(extracted_book_id, Some(book_id)); - assert!(params.is_some()); - assert_eq!(params.unwrap()["force"], true); - } - - #[test] - fn test_generate_thumbnails_extract_fields() { - let library_id = Uuid::new_v4(); - let series_id = Uuid::new_v4(); - - // With series_id (takes precedence) - let task = TaskType::GenerateThumbnails { - library_id: Some(library_id), - series_id: Some(series_id), - series_ids: None, - book_ids: None, - force: true, - }; - - let (type_str, lib_id, extracted_series_id, book_id, params) = task.extract_fields(); - assert_eq!(type_str, "generate_thumbnails"); - assert_eq!(lib_id, Some(library_id)); - assert_eq!(extracted_series_id, Some(series_id)); - assert_eq!(book_id, None); - assert!(params.is_some()); - assert_eq!(params.unwrap()["force"], true); - } - - #[test] - fn test_generate_series_thumbnails_extraction() { - let library_id = Uuid::new_v4(); - - // Library scope - let task = TaskType::GenerateSeriesThumbnails { - library_id: Some(library_id), - series_ids: None, - force: false, - }; - assert_eq!(task.type_string(), "generate_series_thumbnails"); - assert_eq!(task.library_id(), Some(library_id)); - assert_eq!(task.series_id(), None); - assert_eq!(task.book_id(), None); - - let params = task.params(); - assert_eq!(params["force"], false); - - // All scope - let task = TaskType::GenerateSeriesThumbnails { - library_id: None, - series_ids: None, - force: true, - }; - assert_eq!(task.library_id(), None); - assert_eq!(task.series_id(), None); - - let params = task.params(); - assert_eq!(params["force"], true); - } - - #[test] - fn test_generate_series_thumbnails_extract_fields() { - let library_id = Uuid::new_v4(); - - let task = TaskType::GenerateSeriesThumbnails { - library_id: Some(library_id), - series_ids: None, - force: true, - }; - - let (type_str, lib_id, series_id, book_id, params) = task.extract_fields(); - assert_eq!(type_str, "generate_series_thumbnails"); - assert_eq!(lib_id, Some(library_id)); - assert_eq!(series_id, None); - assert_eq!(book_id, None); - assert!(params.is_some()); - assert_eq!(params.unwrap()["force"], true); - } - - #[test] - fn test_cleanup_book_files_extraction() { - let book_id = Uuid::new_v4(); - let series_id = Uuid::new_v4(); - - // Without thumbnail path or series_id - let task = TaskType::CleanupBookFiles { - book_id, - thumbnail_path: None, - series_id: None, - }; - - assert_eq!(task.type_string(), "cleanup_book_files"); - // book_id is NOT returned from book_id() - it's stored in params because - // cleanup tasks reference deleted books that can't have FK constraints - assert_eq!(task.book_id(), None); - assert_eq!(task.library_id(), None); - assert_eq!(task.series_id(), None); - - let (type_str, lib_id, extracted_series_id, extracted_book_id, params) = - task.extract_fields(); - assert_eq!(type_str, "cleanup_book_files"); - assert_eq!(lib_id, None); - assert_eq!(extracted_series_id, None); - assert_eq!(extracted_book_id, None); // Not using FK column - // params should contain book_id, thumbnail_path, and series_id - assert!(params.is_some()); - let params = params.unwrap(); - assert_eq!(params["book_id"], book_id.to_string()); - assert!(params["thumbnail_path"].is_null()); - assert!(params["series_id"].is_null()); - - // With thumbnail path and series_id - let task = TaskType::CleanupBookFiles { - book_id, - thumbnail_path: Some("/data/thumbnails/books/ab/abc123.jpg".to_string()), - series_id: Some(series_id), - }; - - let params = task.params(); - assert_eq!(params["book_id"], book_id.to_string()); - assert_eq!( - params["thumbnail_path"], - "/data/thumbnails/books/ab/abc123.jpg" - ); - assert_eq!(params["series_id"], series_id.to_string()); - } - - #[test] - fn test_cleanup_series_files_extraction() { - let series_id = Uuid::new_v4(); - - let task = TaskType::CleanupSeriesFiles { series_id }; - - assert_eq!(task.type_string(), "cleanup_series_files"); - // series_id is NOT returned from series_id() - it's stored in params because - // cleanup tasks reference deleted series that can't have FK constraints - assert_eq!(task.series_id(), None); - assert_eq!(task.book_id(), None); - assert_eq!(task.library_id(), None); - - let (type_str, lib_id, extracted_series_id, book_id, params) = task.extract_fields(); - assert_eq!(type_str, "cleanup_series_files"); - assert_eq!(lib_id, None); - assert_eq!(extracted_series_id, None); // Not using FK column - assert_eq!(book_id, None); - // params should contain series_id - assert!(params.is_some()); - let params = params.unwrap(); - assert_eq!(params["series_id"], series_id.to_string()); - } - - #[test] - fn test_cleanup_orphaned_files_extraction() { - let task = TaskType::CleanupOrphanedFiles; - - assert_eq!(task.type_string(), "cleanup_orphaned_files"); - assert_eq!(task.library_id(), None); - assert_eq!(task.series_id(), None); - assert_eq!(task.book_id(), None); - - let (type_str, lib_id, series_id, book_id, params) = task.extract_fields(); - assert_eq!(type_str, "cleanup_orphaned_files"); - assert_eq!(lib_id, None); - assert_eq!(series_id, None); - assert_eq!(book_id, None); - assert!(params.is_none()); - } - - #[test] - fn test_cleanup_task_serialization() { - let book_id = Uuid::new_v4(); - let series_id = Uuid::new_v4(); - - // Test CleanupBookFiles serialization - let task = TaskType::CleanupBookFiles { - book_id, - thumbnail_path: Some("/path/to/thumb.jpg".to_string()), - series_id: Some(series_id), - }; - let json = serde_json::to_string(&task).unwrap(); - assert!(json.contains("cleanup_book_files")); - assert!(json.contains(&book_id.to_string())); - - // Test deserialization - let deserialized: TaskType = serde_json::from_str(&json).unwrap(); - assert_eq!(deserialized.type_string(), "cleanup_book_files"); - - // Test CleanupSeriesFiles serialization - let task = TaskType::CleanupSeriesFiles { series_id }; - let json = serde_json::to_string(&task).unwrap(); - assert!(json.contains("cleanup_series_files")); - - // Test CleanupOrphanedFiles serialization - let task = TaskType::CleanupOrphanedFiles; - let json = serde_json::to_string(&task).unwrap(); - assert!(json.contains("cleanup_orphaned_files")); - - let deserialized: TaskType = serde_json::from_str(&json).unwrap(); - assert_eq!(deserialized.type_string(), "cleanup_orphaned_files"); - } - - #[test] - fn test_user_plugin_recommendation_dismiss_extraction() { - let plugin_id = Uuid::new_v4(); - let user_id = Uuid::new_v4(); - - let task = TaskType::UserPluginRecommendationDismiss { - plugin_id, - user_id, - external_id: "12345".to_string(), - reason: Some("not_interested".to_string()), - }; - - assert_eq!(task.type_string(), "user_plugin_recommendation_dismiss"); - assert_eq!(task.library_id(), None); - assert_eq!(task.series_id(), None); - assert_eq!(task.book_id(), None); - - let params = task.params(); - assert_eq!(params["plugin_id"], plugin_id.to_string()); - assert_eq!(params["user_id"], user_id.to_string()); - assert_eq!(params["external_id"], "12345"); - assert_eq!(params["reason"], "not_interested"); - } - - #[test] - fn test_user_plugin_recommendation_dismiss_extract_fields() { - let plugin_id = Uuid::new_v4(); - let user_id = Uuid::new_v4(); - - let task = TaskType::UserPluginRecommendationDismiss { - plugin_id, - user_id, - external_id: "99".to_string(), - reason: None, - }; - - let (type_str, lib_id, series_id, book_id, params) = task.extract_fields(); - assert_eq!(type_str, "user_plugin_recommendation_dismiss"); - assert_eq!(lib_id, None); - assert_eq!(series_id, None); - assert_eq!(book_id, None); - assert!(params.is_some()); - let params = params.unwrap(); - assert_eq!(params["external_id"], "99"); - assert!(params["reason"].is_null()); - } - - #[test] - fn test_user_plugin_recommendation_dismiss_serialization() { - let plugin_id = Uuid::new_v4(); - let user_id = Uuid::new_v4(); - - let task = TaskType::UserPluginRecommendationDismiss { - plugin_id, - user_id, - external_id: "12345".to_string(), - reason: Some("already_read".to_string()), - }; - - let json = serde_json::to_string(&task).unwrap(); - assert!(json.contains("user_plugin_recommendation_dismiss")); - assert!(json.contains(&plugin_id.to_string())); - assert!(json.contains("12345")); - - let deserialized: TaskType = serde_json::from_str(&json).unwrap(); - assert_eq!( - deserialized.type_string(), - "user_plugin_recommendation_dismiss" - ); - } - - #[test] - fn test_renumber_series_extraction() { - let series_id = Uuid::new_v4(); - - let task = TaskType::RenumberSeries { series_id }; - - assert_eq!(task.type_string(), "renumber_series"); - assert_eq!(task.library_id(), None); - assert_eq!(task.series_id(), Some(series_id)); - assert_eq!(task.book_id(), None); - - let (type_str, lib_id, extracted_series_id, book_id, params) = task.extract_fields(); - assert_eq!(type_str, "renumber_series"); - assert_eq!(lib_id, None); - assert_eq!(extracted_series_id, Some(series_id)); - assert_eq!(book_id, None); - // RenumberSeries has no special params, so params should be None (empty object) - assert!(params.is_none()); - } - - #[test] - fn test_renumber_series_serialization() { - let series_id = Uuid::new_v4(); - - let task = TaskType::RenumberSeries { series_id }; - let json = serde_json::to_string(&task).unwrap(); - assert!(json.contains("renumber_series")); - assert!(json.contains(&series_id.to_string())); - - let deserialized: TaskType = serde_json::from_str(&json).unwrap(); - assert_eq!(deserialized.type_string(), "renumber_series"); - assert_eq!(deserialized.series_id(), Some(series_id)); - } - - #[test] - fn test_renumber_series_batch_extraction() { - let id1 = Uuid::new_v4(); - let id2 = Uuid::new_v4(); - - let task = TaskType::RenumberSeriesBatch { - series_ids: Some(vec![id1, id2]), - }; - - assert_eq!(task.type_string(), "renumber_series_batch"); - assert_eq!(task.library_id(), None); - assert_eq!(task.series_id(), None); - assert_eq!(task.book_id(), None); - - let (type_str, lib_id, series_id, book_id, params) = task.extract_fields(); - assert_eq!(type_str, "renumber_series_batch"); - assert_eq!(lib_id, None); - assert_eq!(series_id, None); - assert_eq!(book_id, None); - assert!(params.is_some()); - let params = params.unwrap(); - let ids = params["series_ids"].as_array().unwrap(); - assert_eq!(ids.len(), 2); - } - - #[test] - fn test_renumber_series_batch_empty() { - // Batch with None series_ids - let task = TaskType::RenumberSeriesBatch { series_ids: None }; - - assert_eq!(task.type_string(), "renumber_series_batch"); - let params = task.params(); - assert!(params["series_ids"].is_null()); - } - - #[test] - fn test_refresh_library_metadata_extraction() { - let job_id = Uuid::new_v4(); - let task = TaskType::RefreshLibraryMetadata { job_id }; - - assert_eq!(task.type_string(), "refresh_library_metadata"); - // RefreshLibraryMetadata is scoped by job_id; library is resolved at runtime. - assert_eq!(task.library_id(), None); - assert_eq!(task.job_id(), Some(job_id)); - assert_eq!(task.series_id(), None); - assert_eq!(task.book_id(), None); - assert_eq!(task.default_priority(), 385); - - let (type_str, lib_id, series_id, book_id, params) = task.extract_fields(); - assert_eq!(type_str, "refresh_library_metadata"); - assert!(lib_id.is_none()); - assert!(series_id.is_none()); - assert!(book_id.is_none()); - // job_id is part of the params payload (no dedicated FK column on tasks) - let params = params.expect("expected job_id params"); - assert_eq!(params["job_id"], serde_json::json!(job_id)); - } - - #[test] - fn test_refresh_library_metadata_serialization() { - let job_id = Uuid::new_v4(); - let task = TaskType::RefreshLibraryMetadata { job_id }; - - let json = serde_json::to_string(&task).unwrap(); - assert!(json.contains("refresh_library_metadata")); - assert!(json.contains(&job_id.to_string())); - // jobId is the camelCase rename for the new variant. - assert!(json.contains("jobId")); - - let deserialized: TaskType = serde_json::from_str(&json).unwrap(); - assert_eq!(deserialized.type_string(), "refresh_library_metadata"); - assert_eq!(deserialized.job_id(), Some(job_id)); - } - - #[test] - fn test_poll_release_source_extraction() { - let source_id = Uuid::new_v4(); - let task = TaskType::PollReleaseSource { source_id }; - - assert_eq!(task.type_string(), "poll_release_source"); - assert_eq!(task.library_id(), None); - assert_eq!(task.series_id(), None); - assert_eq!(task.book_id(), None); - assert_eq!(task.default_priority(), 170); - - let (type_str, lib_id, series_id, book_id, params) = task.extract_fields(); - assert_eq!(type_str, "poll_release_source"); - assert_eq!(lib_id, None); - assert_eq!(series_id, None); - assert_eq!(book_id, None); - let params = params.expect("expected source_id params"); - assert_eq!(params["source_id"], serde_json::json!(source_id)); - } - - #[test] - fn test_bulk_track_for_releases_extraction() { - let ids = vec![Uuid::new_v4(), Uuid::new_v4()]; - let task = TaskType::BulkTrackForReleases { - series_ids: ids.clone(), - tracked: true, - }; - - assert_eq!(task.type_string(), "bulk_track_for_releases"); - assert_eq!(task.library_id(), None); - assert_eq!(task.series_id(), None); - assert_eq!(task.book_id(), None); - assert_eq!(task.default_priority(), 155); - - let (type_str, lib_id, series_id, book_id, params) = task.extract_fields(); - assert_eq!(type_str, "bulk_track_for_releases"); - assert_eq!(lib_id, None); - assert_eq!(series_id, None); - assert_eq!(book_id, None); - let params = params.expect("expected series_ids + tracked params"); - assert_eq!(params["tracked"], true); - assert_eq!(params["series_ids"].as_array().unwrap().len(), 2); - } - - #[test] - fn test_bulk_track_for_releases_serialization_roundtrip() { - let ids = vec![Uuid::new_v4()]; - let task = TaskType::BulkTrackForReleases { - series_ids: ids.clone(), - tracked: false, - }; - - let json = serde_json::to_string(&task).unwrap(); - assert!(json.contains("bulk_track_for_releases")); - // camelCase rename on the field, snake_case on the discriminator. - assert!(json.contains("seriesIds")); - assert!(json.contains("\"tracked\":false")); - - let deserialized: TaskType = serde_json::from_str(&json).unwrap(); - match deserialized { - TaskType::BulkTrackForReleases { - series_ids, - tracked, - } => { - assert_eq!(series_ids, ids); - assert!(!tracked); - } - _ => panic!("wrong variant"), - } - } - - #[test] - fn test_poll_release_source_serialization() { - let source_id = Uuid::new_v4(); - let task = TaskType::PollReleaseSource { source_id }; - - let json = serde_json::to_string(&task).unwrap(); - assert!(json.contains("poll_release_source")); - assert!(json.contains(&source_id.to_string())); - // sourceId is the camelCase rename. - assert!(json.contains("sourceId")); - - let deserialized: TaskType = serde_json::from_str(&json).unwrap(); - assert_eq!(deserialized.type_string(), "poll_release_source"); - match deserialized { - TaskType::PollReleaseSource { source_id: id } => { - assert_eq!(id, source_id); - } - _ => panic!("wrong variant"), - } - } - - #[test] - fn test_default_priority_values() { - let library_id = Uuid::new_v4(); - let series_id = Uuid::new_v4(); - let book_id = Uuid::new_v4(); - let plugin_id = Uuid::new_v4(); - let user_id = Uuid::new_v4(); - - // Scanning: highest priority - assert_eq!( - TaskType::ScanLibrary { - library_id, - mode: "normal".to_string() - } - .default_priority(), - 1000 - ); - assert_eq!( - TaskType::PurgeDeleted { library_id }.default_priority(), - 900 - ); - - // Analysis - assert_eq!( - TaskType::AnalyzeBook { - book_id, - force: false - } - .default_priority(), - 800 - ); - assert_eq!( - TaskType::AnalyzeSeries { series_id }.default_priority(), - 790 - ); - assert_eq!( - TaskType::ReprocessSeriesTitle { series_id }.default_priority(), - 780 - ); - assert_eq!( - TaskType::ReprocessSeriesTitles { - library_id: Some(library_id), - series_ids: None - } - .default_priority(), - 770 - ); - assert_eq!( - TaskType::RenumberSeries { series_id }.default_priority(), - 760 - ); - assert_eq!( - TaskType::RenumberSeriesBatch { - series_ids: Some(vec![series_id]) - } - .default_priority(), - 750 - ); - - // Thumbnails - assert_eq!( - TaskType::GenerateThumbnail { - book_id, - force: false - } - .default_priority(), - 600 - ); - assert_eq!( - TaskType::GenerateSeriesThumbnail { - series_id, - force: false - } - .default_priority(), - 590 - ); - assert_eq!( - TaskType::GenerateThumbnails { - library_id: Some(library_id), - series_id: None, - series_ids: None, - book_ids: None, - force: false - } - .default_priority(), - 580 - ); - assert_eq!( - TaskType::GenerateSeriesThumbnails { - library_id: Some(library_id), - series_ids: None, - force: false - } - .default_priority(), - 570 - ); - - // Metadata - assert_eq!(TaskType::FindDuplicates.default_priority(), 400); - assert_eq!( - TaskType::RefreshMetadata { - book_id, - source: "test".to_string() - } - .default_priority(), - 390 - ); - assert_eq!( - TaskType::PluginAutoMatch { - series_id, - plugin_id, - source_scope: None - } - .default_priority(), - 380 - ); - - // Plugins - assert_eq!( - TaskType::UserPluginRecommendationDismiss { - plugin_id, - user_id, - external_id: "test".to_string(), - reason: None - } - .default_priority(), - 200 - ); - assert_eq!( - TaskType::UserPluginSync { plugin_id, user_id }.default_priority(), - 190 - ); - assert_eq!( - TaskType::UserPluginRecommendations { plugin_id, user_id }.default_priority(), - 180 - ); - - // Cleanup: lowest priority - assert_eq!( - TaskType::CleanupBookFiles { - book_id, - thumbnail_path: None, - series_id: None - } - .default_priority(), - 100 - ); - assert_eq!( - TaskType::CleanupSeriesFiles { series_id }.default_priority(), - 100 - ); - assert_eq!(TaskType::CleanupOrphanedFiles.default_priority(), 100); - assert_eq!(TaskType::CleanupPdfCache.default_priority(), 100); - assert_eq!(TaskType::CleanupPluginData.default_priority(), 100); - } - - #[test] - fn test_default_priority_ordering_invariants() { - let library_id = Uuid::new_v4(); - let _series_id = Uuid::new_v4(); - let book_id = Uuid::new_v4(); - - // Scanning > Analysis > Thumbnails > Metadata > Plugins > Cleanup - let scan = TaskType::ScanLibrary { - library_id, - mode: "normal".to_string(), - } - .default_priority(); - let analyze = TaskType::AnalyzeBook { - book_id, - force: false, - } - .default_priority(); - let thumbnail = TaskType::GenerateThumbnail { - book_id, - force: false, - } - .default_priority(); - let metadata = TaskType::FindDuplicates.default_priority(); - let plugin = TaskType::UserPluginSync { - plugin_id: Uuid::new_v4(), - user_id: Uuid::new_v4(), - } - .default_priority(); - let cleanup = TaskType::CleanupOrphanedFiles.default_priority(); - - assert!( - scan > analyze, - "Scanning should have higher priority than analysis" - ); - assert!( - analyze > thumbnail, - "Analysis should have higher priority than thumbnails" - ); - assert!( - thumbnail > metadata, - "Thumbnails should have higher priority than metadata" - ); - assert!( - metadata > plugin, - "Metadata should have higher priority than plugins" - ); - assert!( - plugin > cleanup, - "Plugins should have higher priority than cleanup" - ); - } - - #[test] - fn test_renumber_series_batch_serialization() { - let id1 = Uuid::new_v4(); - - let task = TaskType::RenumberSeriesBatch { - series_ids: Some(vec![id1]), - }; - - let json = serde_json::to_string(&task).unwrap(); - assert!(json.contains("renumber_series_batch")); - assert!(json.contains(&id1.to_string())); - - let deserialized: TaskType = serde_json::from_str(&json).unwrap(); - assert_eq!(deserialized.type_string(), "renumber_series_batch"); - } -} +pub use crate::models::task::*; diff --git a/src/services/plugin/encryption.rs b/src/utils/credential_encryption.rs similarity index 100% rename from src/services/plugin/encryption.rs rename to src/utils/credential_encryption.rs diff --git a/src/utils/jwt.rs b/src/utils/jwt.rs index e3ee2d1e..335f9b39 100644 --- a/src/utils/jwt.rs +++ b/src/utils/jwt.rs @@ -4,7 +4,7 @@ #![allow(dead_code)] -use crate::api::permissions::UserRole; +use crate::models::permissions::UserRole; use anyhow::{Context, Result}; use chrono::{Duration, Utc}; use jsonwebtoken::{DecodingKey, EncodingKey, Header, Validation, decode, encode}; diff --git a/src/utils/mod.rs b/src/utils/mod.rs index 1f6f7f49..bc881013 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -1,3 +1,4 @@ +pub mod credential_encryption; pub mod cron; pub mod deadline; pub mod error; From fdfb28d004b63d1c3aad72f8335a17a7f29162cd Mon Sep 17 00:00:00 2001 From: Sylvain Cau <ashdevfr@gmail.com> Date: Sat, 23 May 2026 14:21:15 -0700 Subject: [PATCH 02/14] refactor(workspace): bootstrap Cargo workspace, extract codex-config + codex-events MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Convert the single-crate codex project into a Cargo workspace and split off the first two leaf crates. crates/codex-config now owns the config types, loader, and env-override plumbing; crates/codex-events owns the entity-change event types, broadcaster, and task-context plumbing. Neither new crate depends on any other Codex-internal crate. The root Cargo.toml gains [workspace.members] and a [workspace.dependencies] table seeded with the deps these crates share with the root (serde, serde_yaml, anyhow, chrono, tokio, tracing, utoipa, uuid, plus tempfile and serial_test for dev). The root [dependencies] inherit them via { workspace = true }; everything else stays inline until cross-crate usage forces it. Profiles remain on the root manifest (Cargo treats them as workspace-wide already). src/lib.rs drops `pub mod config; pub mod events;` and re-exports the new crates with `pub use codex_config as config; pub use codex_events as events;` so external integration tests under tests/ continue to compile unchanged. Inside src/, every `crate::config::` / `crate::events::` reference was rewritten to the explicit `codex_config::` / `codex_events::` paths to make the new dep edges visible at every callsite. One latent events→db coupling surfaced and was cleared as part of the extraction: EntityChangeEvent::release_announced used to take a &release_ledger::Model, which would have dragged codex-db into codex-events. Refactored to take primitive fields (ledger_id, series_id, ...); the two callers destructure the Model at the boundary. cargo build/clippy/fmt clean across the workspace. make test-fast passes; both new crates build in isolation (cargo build -p codex-config / -p codex-events). cargo-dist plan still targets only the codex binary. --- Cargo.lock | 26 ++++++++++ Cargo.toml | 51 +++++++++++++------ crates/codex-config/Cargo.toml | 18 +++++++ .../codex-config/src}/env_override.rs | 26 +++++----- .../mod.rs => crates/codex-config/src/lib.rs | 6 ++- .../codex-config/src}/loader.rs | 2 +- .../codex-config/src}/types.rs | 0 crates/codex-events/Cargo.toml | 20 ++++++++ .../codex-events/src}/broadcaster.rs | 2 +- crates/codex-events/src/lib.rs | 28 ++++++++++ .../codex-events/src}/task_context.rs | 4 +- .../codex-events/src}/types.rs | 33 ++++++++---- src/api/docs.rs | 6 +-- src/api/extractors/auth.rs | 10 ++-- src/api/middleware/rate_limit.rs | 2 +- src/api/routes/mod.rs | 2 +- src/api/routes/v1/handlers/books.rs | 4 +- src/api/routes/v1/handlers/bulk.rs | 2 +- src/api/routes/v1/handlers/bulk_metadata.rs | 2 +- src/api/routes/v1/handlers/events.rs | 2 +- src/api/routes/v1/handlers/libraries.rs | 4 +- src/api/routes/v1/handlers/observability.rs | 2 +- src/api/routes/v1/handlers/plugins.rs | 2 +- src/api/routes/v1/handlers/releases.rs | 2 +- src/api/routes/v1/handlers/scan.rs | 10 ++-- src/api/routes/v1/handlers/series.rs | 2 +- src/api/routes/v1/handlers/tracking.rs | 2 +- src/commands/common.rs | 18 +++---- src/commands/seed.rs | 2 +- src/commands/serve.rs | 6 +-- src/commands/worker.rs | 4 +- src/db/connection.rs | 8 +-- src/db/repositories/alternate_title.rs | 2 +- src/db/repositories/book.rs | 10 ++-- src/db/repositories/series.rs | 10 ++-- src/db/repositories/series_metadata.rs | 2 +- src/db/test_helpers.rs | 4 +- src/events/mod.rs | 23 --------- src/lib.rs | 8 ++- src/main.rs | 2 - src/observability/http.rs | 2 +- src/observability/providers.rs | 8 +-- src/observability/stub.rs | 2 +- src/scanner/analyzer_queue.rs | 4 +- src/scanner/library_scanner.rs | 2 +- src/search/listener.rs | 4 +- src/search/mod.rs | 2 +- src/services/cleanup_subscriber.rs | 4 +- src/services/email.rs | 2 +- src/services/file_cleanup.rs | 2 +- src/services/metadata/apply.rs | 2 +- src/services/metadata/book_apply.rs | 2 +- src/services/metadata/cover.rs | 2 +- src/services/oidc.rs | 2 +- src/services/pdf_handle_cache_subscriber.rs | 4 +- src/services/plugin/handle.rs | 2 +- src/services/plugin/releases_handler.rs | 37 ++++++++------ src/services/rate_limiter.rs | 2 +- src/services/refresh_token.rs | 2 +- src/services/release/tracking_toggle.rs | 2 +- src/services/task_listener.rs | 6 +-- src/services/thumbnail.rs | 4 +- src/tasks/handlers/analyze_book.rs | 2 +- src/tasks/handlers/analyze_series.rs | 2 +- src/tasks/handlers/backfill_tracking.rs | 2 +- src/tasks/handlers/bulk_track_for_releases.rs | 2 +- src/tasks/handlers/cleanup_book_files.rs | 4 +- src/tasks/handlers/cleanup_orphaned_files.rs | 4 +- src/tasks/handlers/cleanup_pdf_cache.rs | 2 +- src/tasks/handlers/cleanup_plugin_data.rs | 2 +- src/tasks/handlers/cleanup_refresh_tokens.rs | 4 +- src/tasks/handlers/cleanup_series_exports.rs | 2 +- src/tasks/handlers/cleanup_series_files.rs | 4 +- src/tasks/handlers/export_series.rs | 2 +- src/tasks/handlers/find_duplicates.rs | 2 +- .../handlers/generate_series_thumbnail.rs | 2 +- .../handlers/generate_series_thumbnails.rs | 2 +- src/tasks/handlers/generate_thumbnail.rs | 2 +- src/tasks/handlers/generate_thumbnails.rs | 2 +- src/tasks/handlers/mod.rs | 2 +- src/tasks/handlers/plugin_auto_match.rs | 2 +- src/tasks/handlers/poll_release_source.rs | 13 +++-- src/tasks/handlers/purge_deleted.rs | 2 +- .../handlers/refresh_library_metadata.rs | 2 +- src/tasks/handlers/renumber_series.rs | 2 +- src/tasks/handlers/reprocess_series_titles.rs | 2 +- src/tasks/handlers/scan_library.rs | 2 +- .../user_plugin_recommendation_dismiss.rs | 2 +- .../handlers/user_plugin_recommendations.rs | 4 +- src/tasks/handlers/user_plugin_sync/mod.rs | 2 +- src/tasks/worker.rs | 18 +++---- 91 files changed, 339 insertions(+), 224 deletions(-) create mode 100644 crates/codex-config/Cargo.toml rename {src/config => crates/codex-config/src}/env_override.rs (98%) rename src/config/mod.rs => crates/codex-config/src/lib.rs (72%) rename {src/config => crates/codex-config/src}/loader.rs (99%) rename {src/config => crates/codex-config/src}/types.rs (100%) create mode 100644 crates/codex-events/Cargo.toml rename {src/events => crates/codex-events/src}/broadcaster.rs (99%) create mode 100644 crates/codex-events/src/lib.rs rename {src/events => crates/codex-events/src}/task_context.rs (98%) rename {src/events => crates/codex-events/src}/types.rs (94%) delete mode 100644 src/events/mod.rs diff --git a/Cargo.lock b/Cargo.lock index 7ed98c4d..05ea4d1d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -821,6 +821,8 @@ dependencies = [ "chrono", "chrono-tz", "clap", + "codex-config", + "codex-events", "cron", "csv", "dashmap", @@ -890,6 +892,30 @@ dependencies = [ "zip", ] +[[package]] +name = "codex-config" +version = "0.0.0" +dependencies = [ + "anyhow", + "serde", + "serde_yaml", + "serial_test", + "tempfile", +] + +[[package]] +name = "codex-events" +version = "0.0.0" +dependencies = [ + "chrono", + "serde", + "serde_json", + "tokio", + "tracing", + "utoipa", + "uuid", +] + [[package]] name = "color_quant" version = "1.1.0" diff --git a/Cargo.toml b/Cargo.toml index bc6eddec..c8e7a1db 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,15 +28,37 @@ observability = [ ] [workspace] -members = [".", "migration"] +members = [".", "migration", "crates/codex-config", "crates/codex-events"] + +# Shared dependencies inherited by workspace members. Only deps that are +# actually consumed by more than one crate live here; the others stay inline in +# the consuming crate's Cargo.toml until they become cross-crate. +[workspace.dependencies] +anyhow = "1.0" +chrono = { version = "0.4", features = ["serde"] } +serde = { version = "1.0", features = ["derive"] } +serde_yaml = "0.9" +tokio = { version = "1", features = ["full"] } +tracing = "0.1" +utoipa = { version = "5.0", features = [ + "axum_extras", + "chrono", + "uuid", + "yaml", +] } +uuid = { version = "1.0", features = ["v4", "serde"] } + +# Shared dev-dependencies +tempfile = "3.13" +serial_test = "3.2" [dependencies] # CLI clap = { version = "4", features = ["derive"] } # Serialization -serde = { version = "1.0", features = ["derive"] } -serde_yaml = "0.9" +serde = { workspace = true } +serde_yaml = { workspace = true } serde_json = "1.0" csv = "1.3" @@ -61,7 +83,7 @@ sha2 = "0.10" md-5 = "0.10" # Error handling -anyhow = "1.0" +anyhow = { workspace = true } thiserror = "2.0" # XML parsing (for ComicInfo.xml) @@ -85,7 +107,7 @@ dirs = "6.0" globset = "0.4" # Date/time -chrono = { version = "0.4", features = ["serde"] } +chrono = { workspace = true } chrono-tz = "0.10" httpdate = "1.0" @@ -107,14 +129,16 @@ sea-orm-migration = { version = "1.1", features = [ "sqlx-sqlite", ] } migration = { path = "migration" } -tokio = { version = "1", features = ["full"] } -uuid = { version = "1.0", features = ["v4", "serde"] } +codex-config = { path = "crates/codex-config" } +codex-events = { path = "crates/codex-events" } +tokio = { workspace = true } +uuid = { workspace = true } # Web framework axum = { version = "0.8", features = ["multipart"] } tower = "0.5" tower-http = { version = "0.6", features = ["trace", "cors", "catch-panic"] } -tracing = "0.1" +tracing = { workspace = true } tracing-subscriber = { version = "0.3", features = ["env-filter"] } tracing-appender = "0.2" log = "0.4" # For sqlx logging level configuration @@ -176,12 +200,7 @@ reqwest = { version = "0.13", default-features = false, features = [ ] } # API Documentation -utoipa = { version = "5.0", features = [ - "axum_extras", - "chrono", - "uuid", - "yaml", -] } +utoipa = { workspace = true } utoipa-scalar = { version = "0.3", features = ["axum"] } # Job Scheduling @@ -202,11 +221,11 @@ rust-embed = "8.5" mime_guess = "2.0" [dev-dependencies] -tempfile = "3.13" +tempfile = { workspace = true } tower = { version = "0.5", features = ["util"] } http-body-util = "0.1" hyper = { version = "1.0", features = ["full"] } -serial_test = "3.2" +serial_test = { workspace = true } tracing-test = "0.2" # Enable the SDK's `testing` feature for the in-memory metric exporter used # in observability::metrics tests. Dev-only; no production impact. diff --git a/crates/codex-config/Cargo.toml b/crates/codex-config/Cargo.toml new file mode 100644 index 00000000..8b75a138 --- /dev/null +++ b/crates/codex-config/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "codex-config" +version = "0.0.0" +edition = "2024" +publish = false + +[lib] +name = "codex_config" +path = "src/lib.rs" + +[dependencies] +serde = { workspace = true } +serde_yaml = { workspace = true } +anyhow = { workspace = true } + +[dev-dependencies] +tempfile = { workspace = true } +serial_test = { workspace = true } diff --git a/src/config/env_override.rs b/crates/codex-config/src/env_override.rs similarity index 98% rename from src/config/env_override.rs rename to crates/codex-config/src/env_override.rs index 90f8d631..bea047e9 100644 --- a/src/config/env_override.rs +++ b/crates/codex-config/src/env_override.rs @@ -742,7 +742,7 @@ mod tests { // Create config with explicit values to avoid reading env vars in default() // We'll use a helper to create a minimal config - use crate::config::{ + use crate::{ ApiConfig, ApplicationConfig, AuthConfig, DatabaseConfig, DatabaseType, EmailConfig, FilesConfig, KomgaApiConfig, LoggingConfig, ObservabilityConfig, PdfConfig, PdfHandleCacheConfig, RateLimitConfig, SQLiteConfig, SchedulerConfig, @@ -931,7 +931,7 @@ mod tests { remove_var("CODEX_KOMGA_API_ENABLED"); remove_var("CODEX_KOMGA_API_PREFIX"); - use crate::config::{ + use crate::{ ApiConfig, ApplicationConfig, AuthConfig, DatabaseConfig, DatabaseType, EmailConfig, FilesConfig, KomgaApiConfig, LoggingConfig, ObservabilityConfig, PdfConfig, PdfHandleCacheConfig, RateLimitConfig, SQLiteConfig, SchedulerConfig, @@ -1140,7 +1140,7 @@ mod tests { remove_var("CODEX_AUTH_OIDC_AUTO_CREATE_USERS"); remove_var("CODEX_AUTH_OIDC_DEFAULT_ROLE"); - use crate::config::{OidcConfig, OidcDefaultRole}; + use crate::{OidcConfig, OidcDefaultRole}; let mut config = OidcConfig { enabled: false, @@ -1170,7 +1170,7 @@ mod tests { fn test_oidc_config_env_override_enabled_with_1() { remove_var("CODEX_AUTH_OIDC_ENABLED"); - use crate::config::{OidcConfig, OidcDefaultRole}; + use crate::{OidcConfig, OidcDefaultRole}; let mut config = OidcConfig { enabled: false, @@ -1191,7 +1191,7 @@ mod tests { #[test] #[serial] fn test_oidc_config_env_override_default_role_variants() { - use crate::config::{OidcConfig, OidcDefaultRole}; + use crate::{OidcConfig, OidcDefaultRole}; // Test maintainer role remove_var("CODEX_AUTH_OIDC_DEFAULT_ROLE"); @@ -1226,7 +1226,7 @@ mod tests { remove_var("CODEX_AUTH_OIDC_PROVIDERS_AUTHENTIK_SCOPES"); remove_var("CODEX_AUTH_OIDC_PROVIDERS_AUTHENTIK_GROUPS_CLAIM"); - use crate::config::OidcProviderConfig; + use crate::OidcProviderConfig; let mut provider = OidcProviderConfig { display_name: "Original".to_string(), @@ -1296,7 +1296,7 @@ mod tests { remove_var("CODEX_AUTH_OIDC_PROVIDERS_AUTHENTIK_CLIENT_ID"); remove_var("CODEX_AUTH_OIDC_PROVIDERS_AUTHENTIK_CLIENT_SECRET"); - use crate::config::{OidcConfig, OidcDefaultRole, OidcProviderConfig}; + use crate::{OidcConfig, OidcDefaultRole, OidcProviderConfig}; let mut providers = std::collections::HashMap::new(); providers.insert( @@ -1355,7 +1355,7 @@ mod tests { remove_var("CODEX_AUTH_OIDC_PROVIDERS_NEWPROVIDER_CLIENT_SECRET"); remove_var("CODEX_AUTH_OIDC_PROVIDERS_NEWPROVIDER_DISPLAY_NAME"); - use crate::config::{OidcConfig, OidcDefaultRole}; + use crate::{OidcConfig, OidcDefaultRole}; let mut config = OidcConfig { enabled: true, @@ -1405,7 +1405,7 @@ mod tests { remove_var("CODEX_AUTH_OIDC_ENABLED"); remove_var("CODEX_AUTH_OIDC_AUTO_CREATE_USERS"); - use crate::config::{AuthConfig, OidcConfig, OidcDefaultRole}; + use crate::{AuthConfig, OidcConfig, OidcDefaultRole}; let mut config = AuthConfig { jwt_secret: "test-secret".to_string(), @@ -1443,7 +1443,7 @@ mod tests { remove_var("CODEX_AUTH_OIDC_PROVIDERS_AUTHENTIK_ROLE_MAPPING_MAINTAINER"); remove_var("CODEX_AUTH_OIDC_PROVIDERS_AUTHENTIK_ROLE_MAPPING_READER"); - use crate::config::OidcProviderConfig; + use crate::OidcProviderConfig; let mut provider = OidcProviderConfig { display_name: "Authentik".to_string(), @@ -1502,7 +1502,7 @@ mod tests { fn test_oidc_provider_role_mapping_env_override_merges_with_existing() { remove_var("CODEX_AUTH_OIDC_PROVIDERS_AUTHENTIK_ROLE_MAPPING_ADMIN"); - use crate::config::OidcProviderConfig; + use crate::OidcProviderConfig; let mut role_mapping = std::collections::HashMap::new(); role_mapping.insert("reader".to_string(), vec!["yaml-readers".to_string()]); @@ -1642,7 +1642,7 @@ mod tests { set_var(k, v); } - let mut config = crate::config::ObservabilityConfig::default(); + let mut config = crate::ObservabilityConfig::default(); config.apply_env_overrides("CODEX_OBSERVABILITY"); assert!(config.enabled); @@ -1650,7 +1650,7 @@ mod tests { assert_eq!(config.otlp.endpoint, "https://otel.example.com:4317"); assert!(matches!( config.otlp.protocol, - crate::config::OtlpProtocol::HttpProtobuf + crate::OtlpProtocol::HttpProtobuf )); assert_eq!(config.otlp.timeout_ms, 9000); assert_eq!(config.otlp.headers.get("x-tenant"), Some(&"acme".into())); diff --git a/src/config/mod.rs b/crates/codex-config/src/lib.rs similarity index 72% rename from src/config/mod.rs rename to crates/codex-config/src/lib.rs index 1d2dd7ba..8c825dbf 100644 --- a/src/config/mod.rs +++ b/crates/codex-config/src/lib.rs @@ -1,8 +1,12 @@ +//! Codex configuration types, loaders, and environment-override plumbing. +//! +//! Extracted from the monolithic `codex` crate as the first workspace leaf in +//! the workspace-split plan. Has no dependencies on other Codex crates. + mod env_override; mod loader; mod types; -// Re-export all config types for external use (used by integration tests) #[allow(unused_imports)] pub use types::{ ApiConfig, ApplicationConfig, AuthConfig, Config, DatabaseConfig, DatabaseType, EmailConfig, diff --git a/src/config/loader.rs b/crates/codex-config/src/loader.rs similarity index 99% rename from src/config/loader.rs rename to crates/codex-config/src/loader.rs index b19816cb..ff1c1deb 100644 --- a/src/config/loader.rs +++ b/crates/codex-config/src/loader.rs @@ -20,7 +20,7 @@ impl Config { #[cfg(test)] mod tests { use super::*; - use crate::config::{ + use crate::{ ApiConfig, ApplicationConfig, AuthConfig, DatabaseConfig, DatabaseType, EmailConfig, FilesConfig, KomgaApiConfig, KoreaderApiConfig, LoggingConfig, ObservabilityConfig, PdfConfig, PdfHandleCacheConfig, RateLimitConfig, SQLiteConfig, ScannerConfig, diff --git a/src/config/types.rs b/crates/codex-config/src/types.rs similarity index 100% rename from src/config/types.rs rename to crates/codex-config/src/types.rs diff --git a/crates/codex-events/Cargo.toml b/crates/codex-events/Cargo.toml new file mode 100644 index 00000000..04716083 --- /dev/null +++ b/crates/codex-events/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "codex-events" +version = "0.0.0" +edition = "2024" +publish = false + +[lib] +name = "codex_events" +path = "src/lib.rs" + +[dependencies] +chrono = { workspace = true } +serde = { workspace = true } +tokio = { workspace = true } +tracing = { workspace = true } +utoipa = { workspace = true } +uuid = { workspace = true } + +[dev-dependencies] +serde_json = "1.0" diff --git a/src/events/broadcaster.rs b/crates/codex-events/src/broadcaster.rs similarity index 99% rename from src/events/broadcaster.rs rename to crates/codex-events/src/broadcaster.rs index e876af5c..cb4d3133 100644 --- a/src/events/broadcaster.rs +++ b/crates/codex-events/src/broadcaster.rs @@ -219,7 +219,7 @@ impl EventBroadcaster { #[cfg(test)] mod tests { use super::*; - use crate::events::types::{EntityEvent, EntityType}; + use crate::types::{EntityEvent, EntityType}; use uuid::Uuid; #[tokio::test] diff --git a/crates/codex-events/src/lib.rs b/crates/codex-events/src/lib.rs new file mode 100644 index 00000000..39b7daf5 --- /dev/null +++ b/crates/codex-events/src/lib.rs @@ -0,0 +1,28 @@ +//! Real-time entity change event system. +//! +//! Provides a broadcast-based event system for notifying clients about entity +//! changes (books, series, libraries) and task progress in real-time via SSE. +//! +//! In distributed deployments where workers run in separate processes, the +//! event recording feature allows capturing events during task execution and +//! replaying them on the web server when tasks complete. +//! +//! Extracted from the monolithic `codex` crate as a workspace leaf. Carries no +//! dependencies on other Codex crates — event payloads use primitive fields +//! rather than db-entity types so the events crate can sit below `codex-db` +//! in the dep graph. + +mod broadcaster; +mod task_context; +mod types; + +pub use broadcaster::{EventBroadcaster, RecordedEvent}; +pub use task_context::{ + TaskIdentity, current_recording_broadcaster, current_task_identity, with_recording_broadcaster, + with_task_identity, +}; +// TaskProgress is part of the public API for task progress reporting +#[allow(unused_imports)] +pub use types::{ + EntityChangeEvent, EntityEvent, EntityType, TaskProgress, TaskProgressEvent, TaskStatus, +}; diff --git a/src/events/task_context.rs b/crates/codex-events/src/task_context.rs similarity index 98% rename from src/events/task_context.rs rename to crates/codex-events/src/task_context.rs index b5219e0a..972e5e8b 100644 --- a/src/events/task_context.rs +++ b/crates/codex-events/src/task_context.rs @@ -14,9 +14,9 @@ //! through every layer of the dispatcher is invasive; the task-local is the //! seam. //! -//! The reverse-RPC dispatcher in [`crate::services::plugin::rpc`] runs the +//! The reverse-RPC dispatcher in [`codex::services::plugin::rpc`] runs the //! dispatch on the *caller's* tokio task (the one that issued the forward -//! call), so the task-local set up by [`crate::tasks::worker`] is in scope. +//! call), so the task-local set up by [`codex::tasks::worker`] is in scope. use std::sync::Arc; use std::sync::Mutex; diff --git a/src/events/types.rs b/crates/codex-events/src/types.rs similarity index 94% rename from src/events/types.rs rename to crates/codex-events/src/types.rs index a449cb50..491a01a5 100644 --- a/src/events/types.rs +++ b/crates/codex-events/src/types.rs @@ -300,28 +300,39 @@ impl EntityChangeEvent { matches!(self.event, EntityEvent::Shutdown) } - /// Build a `ReleaseAnnounced` event from a freshly-inserted ledger row. + /// Build a `ReleaseAnnounced` event from the primitive fields of a + /// freshly-inserted ledger row. + /// + /// Takes individual fields rather than a `release_ledger::Model` so the + /// events crate stays free of any database-entity dependency. Callers in + /// the polling task and the reverse-RPC handler destructure their + /// `Model` at the boundary; this keeps the event-shape source of truth + /// in one place. /// - /// Wraps the variant construction so callers in the polling task and the - /// reverse-RPC handler share one source of truth for the event shape. /// `series_title` should be the canonical display title for the series /// (typically `series_metadata.title`, falling back to the series /// directory name); the frontend renders it as a clickable link. + #[allow(clippy::too_many_arguments)] // event payload has many fields by design pub fn release_announced( - row: &crate::db::entities::release_ledger::Model, - plugin_id: &str, + ledger_id: Uuid, + series_id: Uuid, series_title: String, + source_id: Uuid, + plugin_id: &str, + chapter: Option<f64>, + volume: Option<i32>, + language: Option<String>, ) -> Self { Self::new( EntityEvent::ReleaseAnnounced { - ledger_id: row.id, - series_id: row.series_id, + ledger_id, + series_id, series_title, - source_id: row.source_id, + source_id, plugin_id: plugin_id.to_string(), - chapter: row.chapter, - volume: row.volume, - language: row.language.clone().unwrap_or_default(), + chapter, + volume, + language: language.unwrap_or_default(), }, None, ) diff --git a/src/api/docs.rs b/src/api/docs.rs index 9ee8c162..cfc7ec2b 100644 --- a/src/api/docs.rs +++ b/src/api/docs.rs @@ -1035,9 +1035,9 @@ The following paths are exempt from rate limiting: v1::dto::PluginCleanupResultDto, // SSE Event DTOs - crate::events::EntityChangeEvent, - crate::events::EntityEvent, - crate::events::TaskProgressEvent, + codex_events::EntityChangeEvent, + codex_events::EntityEvent, + codex_events::TaskProgressEvent, // Error responses ErrorResponse, diff --git a/src/api/extractors/auth.rs b/src/api/extractors/auth.rs index 86fb4e67..a84dacae 100644 --- a/src/api/extractors/auth.rs +++ b/src/api/extractors/auth.rs @@ -175,17 +175,17 @@ pub struct AppState { /// Always present; the [`AuthConfig::refresh_token_enabled`] flag gates /// whether handlers actually call `issue` on login. pub refresh_token_service: Arc<crate::services::RefreshTokenService>, - pub auth_config: Arc<crate::config::AuthConfig>, + pub auth_config: Arc<codex_config::AuthConfig>, /// Database configuration - used for operation deadlines and pool settings - pub database_config: Arc<crate::config::DatabaseConfig>, + pub database_config: Arc<codex_config::DatabaseConfig>, /// PDF configuration - used for rendering settings and cache config - pub pdf_config: Arc<crate::config::PdfConfig>, + pub pdf_config: Arc<codex_config::PdfConfig>, /// Observability configuration - used by the browser RUM SDK bootstrap /// endpoint and the OTLP forwarding proxy. Always present; handlers gate /// behavior on `browser.enabled` / `otlp.endpoint`. - pub observability_config: Arc<crate::config::ObservabilityConfig>, + pub observability_config: Arc<codex_config::ObservabilityConfig>, pub email_service: Arc<crate::services::email::EmailService>, - pub event_broadcaster: Arc<crate::events::EventBroadcaster>, + pub event_broadcaster: Arc<codex_events::EventBroadcaster>, /// Settings service - used for runtime configuration #[allow(dead_code)] pub settings_service: Arc<crate::services::SettingsService>, diff --git a/src/api/middleware/rate_limit.rs b/src/api/middleware/rate_limit.rs index 4b6e6466..81542d9d 100644 --- a/src/api/middleware/rate_limit.rs +++ b/src/api/middleware/rate_limit.rs @@ -457,7 +457,7 @@ mod tests { #[test] fn test_rate_limit_layer_creation() { - let config = Arc::new(crate::config::RateLimitConfig::default()); + let config = Arc::new(codex_config::RateLimitConfig::default()); let service = Arc::new(RateLimiterService::new(config)); let layer = RateLimitLayer::new( service, diff --git a/src/api/routes/mod.rs b/src/api/routes/mod.rs index a5d6484c..22891f28 100644 --- a/src/api/routes/mod.rs +++ b/src/api/routes/mod.rs @@ -7,9 +7,9 @@ pub mod v1; use crate::api::docs::ApiDoc; use crate::api::extractors::AppState; use crate::api::middleware::{RateLimitLayer, create_trace_layer}; -use crate::config::Config; use crate::web; use axum::{Router, routing::get}; +use codex_config::Config; use std::sync::Arc; use tower_http::catch_panic::CatchPanicLayer; use tower_http::cors::{Any, CorsLayer}; diff --git a/src/api/routes/v1/handlers/books.rs b/src/api/routes/v1/handlers/books.rs index bdbd74fc..784ddc6f 100644 --- a/src/api/routes/v1/handlers/books.rs +++ b/src/api/routes/v1/handlers/books.rs @@ -2135,8 +2135,8 @@ use crate::api::routes::v1::dto::{ BookMetadataResponse, PatchBookMetadataRequest, ReplaceBookMetadataRequest, }; use crate::db::entities::book_metadata; -use crate::events::{EntityChangeEvent, EntityEvent}; use chrono::Utc; +use codex_events::{EntityChangeEvent, EntityEvent}; use sea_orm::{ActiveModelTrait, Set}; /// Replace all book metadata (PUT) @@ -3403,8 +3403,8 @@ pub async fn update_book_metadata_locks( // Book Cover Upload Endpoint // ============================================================================ -use crate::events::EntityType; use axum::extract::Multipart; +use codex_events::EntityType; use tokio::fs; use tokio::io::AsyncWriteExt; diff --git a/src/api/routes/v1/handlers/bulk.rs b/src/api/routes/v1/handlers/bulk.rs index c976358d..3fd1a5b4 100644 --- a/src/api/routes/v1/handlers/bulk.rs +++ b/src/api/routes/v1/handlers/bulk.rs @@ -16,11 +16,11 @@ use crate::db::repositories::{ SeriesMetadataRepository, SeriesRepository, SharingTagRepository, TagRepository, TaskRepository, }; -use crate::events::{EntityChangeEvent, EntityEvent}; use crate::require_permission; use crate::tasks::types::TaskType; use axum::{Json, extract::State}; use chrono::Utc; +use codex_events::{EntityChangeEvent, EntityEvent}; use std::sync::Arc; use uuid::Uuid; diff --git a/src/api/routes/v1/handlers/bulk_metadata.rs b/src/api/routes/v1/handlers/bulk_metadata.rs index c6173c18..541aa778 100644 --- a/src/api/routes/v1/handlers/bulk_metadata.rs +++ b/src/api/routes/v1/handlers/bulk_metadata.rs @@ -10,7 +10,6 @@ use crate::db::repositories::{ BookMetadataRepository, BookRepository, GenreRepository, SeriesMetadataRepository, SeriesRepository, TagRepository, }; -use crate::events::{EntityChangeEvent, EntityEvent}; use crate::require_permission; use crate::utils::{ json_merge_patch, parse_custom_metadata, serialize_custom_metadata, @@ -18,6 +17,7 @@ use crate::utils::{ }; use axum::{Json, extract::State}; use chrono::Utc; +use codex_events::{EntityChangeEvent, EntityEvent}; use sea_orm::{ActiveModelTrait, Set}; use std::sync::Arc; use uuid::Uuid; diff --git a/src/api/routes/v1/handlers/events.rs b/src/api/routes/v1/handlers/events.rs index 18b90db2..2cffe089 100644 --- a/src/api/routes/v1/handlers/events.rs +++ b/src/api/routes/v1/handlers/events.rs @@ -251,7 +251,7 @@ pub async fn task_progress_stream( #[cfg(test)] mod tests { - use crate::events::{EntityChangeEvent, EntityEvent, EventBroadcaster}; + use codex_events::{EntityChangeEvent, EntityEvent, EventBroadcaster}; use uuid::Uuid; #[tokio::test] diff --git a/src/api/routes/v1/handlers/libraries.rs b/src/api/routes/v1/handlers/libraries.rs index d8aafa23..2fff635d 100644 --- a/src/api/routes/v1/handlers/libraries.rs +++ b/src/api/routes/v1/handlers/libraries.rs @@ -456,7 +456,7 @@ pub async fn update_library( // Emit LibraryUpdated event { - use crate::events::{EntityChangeEvent, EntityEvent}; + use codex_events::{EntityChangeEvent, EntityEvent}; let event = EntityChangeEvent { event: EntityEvent::LibraryUpdated { library_id }, @@ -521,7 +521,7 @@ pub async fn delete_library( // Emit LibraryDeleted event { - use crate::events::{EntityChangeEvent, EntityEvent}; + use codex_events::{EntityChangeEvent, EntityEvent}; let event = EntityChangeEvent { event: EntityEvent::LibraryDeleted { library_id }, diff --git a/src/api/routes/v1/handlers/observability.rs b/src/api/routes/v1/handlers/observability.rs index 6a8fe08f..26ad3a5d 100644 --- a/src/api/routes/v1/handlers/observability.rs +++ b/src/api/routes/v1/handlers/observability.rs @@ -23,7 +23,7 @@ use crate::api::{ error::ApiError, extractors::{AppState, FlexibleAuthContext}, }; -use crate::config::ObservabilityConfig; +use codex_config::ObservabilityConfig; use super::super::dto::BrowserObservabilityConfigDto; diff --git a/src/api/routes/v1/handlers/plugins.rs b/src/api/routes/v1/handlers/plugins.rs index ede2ad73..57515ccd 100644 --- a/src/api/routes/v1/handlers/plugins.rs +++ b/src/api/routes/v1/handlers/plugins.rs @@ -13,7 +13,6 @@ use super::super::dto::{ use crate::api::{AppState, error::ApiError, extractors::AuthContext, permissions::Permission}; use crate::db::entities::plugins::{InternalPluginConfig, PluginPermission}; use crate::db::repositories::{PluginFailuresRepository, PluginsRepository, UserPluginsRepository}; -use crate::events::{EntityChangeEvent, EntityEvent}; use crate::services::PluginHealthStatus; use crate::services::plugin::process::{allowed_commands_description, is_command_allowed}; use crate::services::plugin::protocol::PluginScope; @@ -22,6 +21,7 @@ use axum::{ extract::{Path, State}, http::StatusCode, }; +use codex_events::{EntityChangeEvent, EntityEvent}; use std::sync::Arc; use std::time::Instant; use utoipa::OpenApi; diff --git a/src/api/routes/v1/handlers/releases.rs b/src/api/routes/v1/handlers/releases.rs index 5cb3e18e..6bb68564 100644 --- a/src/api/routes/v1/handlers/releases.rs +++ b/src/api/routes/v1/handlers/releases.rs @@ -45,7 +45,7 @@ use crate::db::repositories::{ LedgerInboxFilter, LibraryRepository, PluginsRepository, ReleaseLedgerRepository, ReleaseSourceRepository, ReleaseSourceUpdate, SeriesRepository, }; -use crate::events::{EntityChangeEvent, EntityEvent}; +use codex_events::{EntityChangeEvent, EntityEvent}; /// Hydrate ledger rows with series titles via a single batched lookup. /// diff --git a/src/api/routes/v1/handlers/scan.rs b/src/api/routes/v1/handlers/scan.rs index 8e853cb4..fb178174 100644 --- a/src/api/routes/v1/handlers/scan.rs +++ b/src/api/routes/v1/handlers/scan.rs @@ -324,14 +324,14 @@ pub async fn scan_progress_stream( }; let status_str = match event.status { - crate::events::TaskStatus::Pending => "pending", - crate::events::TaskStatus::Running => "running", - crate::events::TaskStatus::Completed => "completed", - crate::events::TaskStatus::Failed => "failed", + codex_events::TaskStatus::Pending => "pending", + codex_events::TaskStatus::Running => "running", + codex_events::TaskStatus::Completed => "completed", + codex_events::TaskStatus::Failed => "failed", }; // For completed tasks, try to extract scan counts from task result - let (series_found, books_found) = if event.status == crate::events::TaskStatus::Completed { + let (series_found, books_found) = if event.status == codex_events::TaskStatus::Completed { // Query task result to get actual scan counts match TaskRepository::get_by_id(&db, event.task_id).await { Ok(Some(task)) if task.result.is_some() => { diff --git a/src/api/routes/v1/handlers/series.rs b/src/api/routes/v1/handlers/series.rs index b8866391..a900b5ef 100644 --- a/src/api/routes/v1/handlers/series.rs +++ b/src/api/routes/v1/handlers/series.rs @@ -33,7 +33,6 @@ use crate::db::repositories::{ SeriesExternalIdRepository, SeriesMetadataRepository, SeriesRepository, SeriesTrackingRepository, SharingTagRepository, TagRepository, UserSeriesRatingRepository, }; -use crate::events::{EntityChangeEvent, EntityEvent, EntityType}; use crate::require_permission; use crate::services::release::upstream_gap::{ UpstreamGap, UpstreamGapInputs, compute_upstream_gap, @@ -50,6 +49,7 @@ use axum::{ response::{IntoResponse, Response}, }; use chrono::Utc; +use codex_events::{EntityChangeEvent, EntityEvent, EntityType}; use httpdate::fmt_http_date; use sea_orm::DatabaseConnection; use serde::Deserialize; diff --git a/src/api/routes/v1/handlers/tracking.rs b/src/api/routes/v1/handlers/tracking.rs index d7ad2be2..7d9fe8f8 100644 --- a/src/api/routes/v1/handlers/tracking.rs +++ b/src/api/routes/v1/handlers/tracking.rs @@ -29,9 +29,9 @@ use crate::db::entities::series_aliases::alias_source; use crate::db::repositories::{ SeriesAliasRepository, SeriesRepository, SeriesTrackingRepository, TrackingUpdate, }; -use crate::events::{EntityChangeEvent, EntityEvent}; use crate::require_permission; use crate::services::release::seed::seed_tracking_for_series; +use codex_events::{EntityChangeEvent, EntityEvent}; // ============================================================================= // Tracking config handlers diff --git a/src/commands/common.rs b/src/commands/common.rs index 6c4d38de..72882ec5 100644 --- a/src/commands/common.rs +++ b/src/commands/common.rs @@ -1,9 +1,9 @@ -use crate::config::{Config, DatabaseConfig, DatabaseType, EnvOverride}; use crate::db::Database; -use crate::events::EventBroadcaster; use crate::observability::ObservabilityHandle; use crate::services::{SettingsService, TaskMetricsService}; use crate::tasks::TaskWorker; +use codex_config::{Config, DatabaseConfig, DatabaseType, EnvOverride}; +use codex_events::EventBroadcaster; use sea_orm::DatabaseConnection; use std::fs; use std::path::{Path, PathBuf}; @@ -442,7 +442,7 @@ pub async fn init_settings_service( /// Get worker count from config (which already includes env override) /// Falls back to settings if config not available (for backward compatibility) pub async fn get_worker_count( - config: Option<&crate::config::TaskConfig>, + config: Option<&codex_config::TaskConfig>, settings_service: Option<&SettingsService>, ) -> u32 { // Priority: config (with env override) > settings > default @@ -469,7 +469,7 @@ pub fn spawn_workers( settings_service: Arc<SettingsService>, thumbnail_service: Arc<crate::services::ThumbnailService>, task_metrics_service: Option<Arc<TaskMetricsService>>, - files_config: crate::config::FilesConfig, + files_config: codex_config::FilesConfig, pdf_page_cache: Option<Arc<crate::services::PdfPageCache>>, pdf_handle_cache: Option<Arc<crate::services::PdfHandleCache>>, plugin_manager: Option<Arc<crate::services::plugin::PluginManager>>, @@ -583,9 +583,9 @@ pub async fn shutdown_workers( #[cfg(test)] mod tests { use super::*; - use crate::config::{FilesConfig, SQLiteConfig, TaskConfig}; use crate::db::test_helpers::create_test_db; use crate::services::SettingsService; + use codex_config::{FilesConfig, SQLiteConfig, TaskConfig}; use tempfile::TempDir; #[test] @@ -674,8 +674,8 @@ mod tests { .to_string_lossy() .to_string(), }, - database: crate::config::DatabaseConfig { - db_type: crate::config::DatabaseType::SQLite, + database: codex_config::DatabaseConfig { + db_type: codex_config::DatabaseType::SQLite, sqlite: Some(SQLiteConfig { path: db_path.to_string_lossy().to_string(), pragmas: None, @@ -683,9 +683,9 @@ mod tests { }), postgres: None, }, - pdf: crate::config::PdfConfig { + pdf: codex_config::PdfConfig { cache_dir: pdf_cache_dir.to_string_lossy().to_string(), - ..crate::config::PdfConfig::default() + ..codex_config::PdfConfig::default() }, ..Config::default() }; diff --git a/src/commands/seed.rs b/src/commands/seed.rs index e9ac885f..b877af73 100644 --- a/src/commands/seed.rs +++ b/src/commands/seed.rs @@ -1,7 +1,6 @@ use crate::api::permissions::{ ADMIN_PERMISSIONS, MAINTAINER_PERMISSIONS, READER_PERMISSIONS, serialize_permissions, }; -use crate::config::{Config, EnvOverride}; use crate::db::Database; use crate::db::entities::{api_keys, plugins::PluginPermission, users}; use crate::db::repositories::{ @@ -13,6 +12,7 @@ use crate::services::plugin::protocol::PluginScope; use crate::utils::password::hash_password; use anyhow::{Context, Result}; use chrono::Utc; +use codex_config::{Config, EnvOverride}; use rand::RngExt; use serde::Deserialize; use std::collections::HashMap; diff --git a/src/commands/serve.rs b/src/commands/serve.rs index 02d43aed..c187f500 100644 --- a/src/commands/serve.rs +++ b/src/commands/serve.rs @@ -3,7 +3,7 @@ use crate::commands::common::{ init_database, init_settings_service, init_tracing, load_config, shutdown_workers, spawn_workers, }; -use crate::config::DatabaseType; +use codex_config::DatabaseType; use std::path::PathBuf; use std::sync::Arc; use tokio::signal; @@ -57,7 +57,7 @@ pub async fn serve_command(config_path: PathBuf) -> anyhow::Result<()> { init_settings_service(db.sea_orm_connection(), background_task_cancel.clone()).await?; // Create event broadcaster for real-time updates - let event_broadcaster = Arc::new(crate::events::EventBroadcaster::new(1000)); + let event_broadcaster = Arc::new(codex_events::EventBroadcaster::new(1000)); info!("Event broadcaster initialized"); // Start cleanup event subscriber to handle file cleanup on entity deletion @@ -337,7 +337,7 @@ pub async fn serve_command(config_path: PathBuf) -> anyhow::Result<()> { // Note: no broadcaster injection. Reverse-RPC handlers (e.g. // `releases/record`) emit through the task-local recording broadcaster // set up by `TaskWorker::run_task`, not through a manager-held one. - // See `crate::events::with_recording_broadcaster`. + // See `codex_events::with_recording_broadcaster`. info!("Initializing plugin manager..."); // Wrap the scheduler in the services-layer trait so plugin handles can // trigger reconciles without holding the concrete scheduler type. diff --git a/src/commands/worker.rs b/src/commands/worker.rs index 8ea3126b..e6df5dea 100644 --- a/src/commands/worker.rs +++ b/src/commands/worker.rs @@ -55,7 +55,7 @@ pub async fn worker_command(config_path: PathBuf) -> anyhow::Result<()> { info!("Starting {} task queue worker(s)...", worker_count); // Create event broadcaster for real-time updates (workers don't need to emit events, but handlers might) - let event_broadcaster = Arc::new(crate::events::EventBroadcaster::new(1000)); + let event_broadcaster = Arc::new(codex_events::EventBroadcaster::new(1000)); info!("Event broadcaster initialized"); // Initialize thumbnail service @@ -132,7 +132,7 @@ pub async fn worker_command(config_path: PathBuf) -> anyhow::Result<()> { // Note: no broadcaster injection. Reverse-RPC handlers (e.g. // `releases/record`) emit through the task-local recording broadcaster // set up by `TaskWorker::run_task`, not through a manager-held one. - // See `crate::events::with_recording_broadcaster`. + // See `codex_events::with_recording_broadcaster`. info!("Initializing plugin manager..."); let plugin_manager = Arc::new( crate::services::plugin::PluginManager::with_defaults(Arc::new( diff --git a/src/db/connection.rs b/src/db/connection.rs index b614163d..372c8237 100644 --- a/src/db/connection.rs +++ b/src/db/connection.rs @@ -13,8 +13,8 @@ use tracing::info; use uuid::Uuid; use super::ScanningStrategy; -use crate::config::{DatabaseConfig, DatabaseType}; use crate::db::entities; +use codex_config::{DatabaseConfig, DatabaseType}; use super::repositories::{ BookMetadataRepository, BookRepository, LibraryRepository, PageRepository, SeriesRepository, @@ -462,7 +462,7 @@ impl Database { #[cfg(test)] mod tests { use super::*; - use crate::config::{DatabaseConfig, DatabaseType, SQLiteConfig}; + use codex_config::{DatabaseConfig, DatabaseType, SQLiteConfig}; use tempfile::TempDir; #[tokio::test] @@ -499,7 +499,7 @@ mod tests { async fn test_database_new_postgres() { let config = DatabaseConfig { db_type: DatabaseType::Postgres, - postgres: Some(crate::config::PostgresConfig { + postgres: Some(codex_config::PostgresConfig { host: std::env::var("POSTGRES_HOST").unwrap_or_else(|_| "localhost".to_string()), port: std::env::var("POSTGRES_PORT") .ok() @@ -511,7 +511,7 @@ mod tests { .unwrap_or_else(|_| "codex_test".to_string()), database_name: std::env::var("POSTGRES_DB") .unwrap_or_else(|_| "codex_test".to_string()), - ..crate::config::PostgresConfig::default() + ..codex_config::PostgresConfig::default() }), sqlite: None, }; diff --git a/src/db/repositories/alternate_title.rs b/src/db/repositories/alternate_title.rs index 33a05372..cbcbe263 100644 --- a/src/db/repositories/alternate_title.rs +++ b/src/db/repositories/alternate_title.rs @@ -13,7 +13,7 @@ use uuid::Uuid; use crate::db::entities::series_alternate_titles::{ self, Entity as AlternateTitles, Model as AlternateTitle, }; -use crate::events::{EntityChangeEvent, EntityEvent, EventBroadcaster}; +use codex_events::{EntityChangeEvent, EntityEvent, EventBroadcaster}; /// Repository for series alternate title operations pub struct AlternateTitleRepository; diff --git a/src/db/repositories/book.rs b/src/db/repositories/book.rs index 3fd99d29..1ce2811b 100644 --- a/src/db/repositories/book.rs +++ b/src/db/repositories/book.rs @@ -16,9 +16,9 @@ use uuid::Uuid; use crate::db::entities::{books, prelude::*}; use crate::db::repositories::SeriesRepository; -use crate::events::{EntityChangeEvent, EntityEvent, EventBroadcaster}; use crate::observability::repo::db_system_str; use crate::utils::normalize_for_search; +use codex_events::{EntityChangeEvent, EntityEvent, EventBroadcaster}; /// Options for querying books with filtering, sorting, and pagination #[derive(Debug, Clone, Default)] @@ -1924,7 +1924,7 @@ impl BookRepository { pub async fn purge_deleted_in_library( db: &DatabaseConnection, library_id: Uuid, - event_broadcaster: Option<&Arc<crate::events::EventBroadcaster>>, + event_broadcaster: Option<&Arc<codex_events::EventBroadcaster>>, ) -> Result<u64> { // Get all series in the library let series_list = @@ -1955,7 +1955,7 @@ impl BookRepository { // Emit BookDeleted events for each purged book if let Some(broadcaster) = event_broadcaster { - use crate::events::{EntityChangeEvent, EntityEvent}; + use codex_events::{EntityChangeEvent, EntityEvent}; use tracing::warn; for book in books_to_delete { @@ -2006,7 +2006,7 @@ impl BookRepository { pub async fn purge_deleted_in_series( db: &DatabaseConnection, series_id: Uuid, - event_broadcaster: Option<&Arc<crate::events::EventBroadcaster>>, + event_broadcaster: Option<&Arc<codex_events::EventBroadcaster>>, ) -> Result<u64> { // First, fetch the series to get library_id and all books that will be deleted let series = crate::db::repositories::SeriesRepository::get_by_id(db, series_id) @@ -2031,7 +2031,7 @@ impl BookRepository { // Emit BookDeleted events for each purged book if let Some(broadcaster) = event_broadcaster { - use crate::events::{EntityChangeEvent, EntityEvent}; + use codex_events::{EntityChangeEvent, EntityEvent}; use tracing::warn; for book in books_to_delete { diff --git a/src/db/repositories/series.rs b/src/db/repositories/series.rs index a02d6581..57733295 100644 --- a/src/db/repositories/series.rs +++ b/src/db/repositories/series.rs @@ -17,10 +17,10 @@ use crate::db::entities::{ book_metadata, books, prelude::*, read_progress, series, series_external_ratings, series_metadata, user_series_ratings, }; -use crate::events::{EntityChangeEvent, EntityEvent, EventBroadcaster}; use crate::models::sort::{SeriesSortField, SeriesSortParam, SortDirection}; use crate::observability::repo::db_system_str; use crate::utils::normalize_for_search; +use codex_events::{EntityChangeEvent, EntityEvent, EventBroadcaster}; use std::sync::Arc; /// Options for querying series with filtering, sorting, and pagination @@ -2235,7 +2235,7 @@ impl SeriesRepository { pub async fn purge_empty_series_in_library( db: &DatabaseConnection, library_id: Uuid, - event_broadcaster: Option<&Arc<crate::events::EventBroadcaster>>, + event_broadcaster: Option<&Arc<codex_events::EventBroadcaster>>, ) -> Result<u64> { use crate::db::entities::{books, prelude::*}; @@ -2270,7 +2270,7 @@ impl SeriesRepository { // Emit SeriesDeleted event if let Some(broadcaster) = event_broadcaster { - use crate::events::{EntityChangeEvent, EntityEvent}; + use codex_events::{EntityChangeEvent, EntityEvent}; use tracing::warn; let event = EntityChangeEvent { @@ -2299,7 +2299,7 @@ impl SeriesRepository { pub async fn purge_if_empty( db: &DatabaseConnection, series_id: Uuid, - event_broadcaster: Option<&Arc<crate::events::EventBroadcaster>>, + event_broadcaster: Option<&Arc<codex_events::EventBroadcaster>>, ) -> Result<bool> { use crate::db::entities::books; @@ -2328,7 +2328,7 @@ impl SeriesRepository { // Emit SeriesDeleted event if let Some(broadcaster) = event_broadcaster { - use crate::events::{EntityChangeEvent, EntityEvent}; + use codex_events::{EntityChangeEvent, EntityEvent}; use tracing::warn; let event = EntityChangeEvent { diff --git a/src/db/repositories/series_metadata.rs b/src/db/repositories/series_metadata.rs index 9e8cf91d..3f1e6aa5 100644 --- a/src/db/repositories/series_metadata.rs +++ b/src/db/repositories/series_metadata.rs @@ -11,8 +11,8 @@ use std::sync::Arc; use uuid::Uuid; use crate::db::entities::{series_metadata, series_metadata::Entity as SeriesMetadata}; -use crate::events::{EntityChangeEvent, EntityEvent, EventBroadcaster}; use crate::utils::normalize_for_search; +use codex_events::{EntityChangeEvent, EntityEvent, EventBroadcaster}; /// Repository for series metadata operations pub struct SeriesMetadataRepository; diff --git a/src/db/test_helpers.rs b/src/db/test_helpers.rs index b5e98766..a28076ad 100644 --- a/src/db/test_helpers.rs +++ b/src/db/test_helpers.rs @@ -1,8 +1,8 @@ #[cfg(test)] -use crate::config::{DatabaseConfig, DatabaseType, SQLiteConfig}; -#[cfg(test)] use crate::db::Database; #[cfg(test)] +use codex_config::{DatabaseConfig, DatabaseType, SQLiteConfig}; +#[cfg(test)] use tempfile::TempDir; /// Helper to create a test SQLite database with migrations applied diff --git a/src/events/mod.rs b/src/events/mod.rs deleted file mode 100644 index 7daacf4a..00000000 --- a/src/events/mod.rs +++ /dev/null @@ -1,23 +0,0 @@ -//! Real-time entity change event system -//! -//! This module provides a broadcast-based event system for notifying clients -//! about entity changes (books, series, libraries) and task progress in real-time via SSE. -//! -//! In distributed deployments where workers run in separate processes, the event -//! recording feature allows capturing events during task execution and replaying -//! them on the web server when tasks complete. - -mod broadcaster; -mod task_context; -mod types; - -pub use broadcaster::{EventBroadcaster, RecordedEvent}; -pub use task_context::{ - TaskIdentity, current_recording_broadcaster, current_task_identity, with_recording_broadcaster, - with_task_identity, -}; -// TaskProgress is part of the public API for task progress reporting -#[allow(unused_imports)] -pub use types::{ - EntityChangeEvent, EntityEvent, EntityType, TaskProgress, TaskProgressEvent, TaskStatus, -}; diff --git a/src/lib.rs b/src/lib.rs index 6831fdae..f955bf49 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,7 +1,5 @@ pub mod api; -pub mod config; pub mod db; -pub mod events; pub mod models; pub mod observability; pub mod parsers; @@ -12,3 +10,9 @@ pub mod services; pub mod tasks; pub mod utils; pub mod web; + +// Re-exports of workspace-leaf crates so existing `codex::config::*` and +// `codex::events::*` paths (used pervasively in integration tests) keep +// resolving without churn. +pub use codex_config as config; +pub use codex_events as events; diff --git a/src/main.rs b/src/main.rs index 2064e1ea..5804fdd9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,8 +1,6 @@ mod api; mod commands; -mod config; mod db; -mod events; mod models; mod observability; mod parsers; diff --git a/src/observability/http.rs b/src/observability/http.rs index dced7b7e..bdd4563f 100644 --- a/src/observability/http.rs +++ b/src/observability/http.rs @@ -7,7 +7,7 @@ use axum::Router; -use crate::config::ObservabilityConfig; +use codex_config::ObservabilityConfig; /// Apply the HTTP server-side OTel layers to the given router. /// diff --git a/src/observability/providers.rs b/src/observability/providers.rs index 2a88931b..2025accb 100644 --- a/src/observability/providers.rs +++ b/src/observability/providers.rs @@ -13,7 +13,7 @@ use opentelemetry_sdk::{ }; use opentelemetry_semantic_conventions::resource::SERVICE_VERSION; -use crate::config::{ObservabilityConfig, OtlpProtocol}; +use codex_config::{ObservabilityConfig, OtlpProtocol}; const TRACER_INSTRUMENTATION_NAME: &str = "codex"; @@ -297,17 +297,17 @@ mod tests { ObservabilityConfig { enabled: true, service_name: "codex-test".to_string(), - otlp: crate::config::OtlpConfig { + otlp: codex_config::OtlpConfig { endpoint: "http://127.0.0.1:14318".to_string(), protocol: OtlpProtocol::HttpProtobuf, headers: Default::default(), timeout_ms: 1000, }, - traces: crate::config::ObservabilityTracesConfig { + traces: codex_config::ObservabilityTracesConfig { enabled: true, sample_ratio: 1.0, }, - metrics: crate::config::ObservabilityMetricsConfig { + metrics: codex_config::ObservabilityMetricsConfig { enabled: true, export_interval_ms: 1000, }, diff --git a/src/observability/stub.rs b/src/observability/stub.rs index d02a96f9..aeb1f806 100644 --- a/src/observability/stub.rs +++ b/src/observability/stub.rs @@ -11,7 +11,7 @@ use tracing_subscriber::{ registry::LookupSpan, }; -use crate::config::ObservabilityConfig; +use codex_config::ObservabilityConfig; /// Empty handle. All accessors return as if observability is disabled. pub struct ObservabilityHandle; diff --git a/src/scanner/analyzer_queue.rs b/src/scanner/analyzer_queue.rs index 62d1a2db..767ed84b 100644 --- a/src/scanner/analyzer_queue.rs +++ b/src/scanner/analyzer_queue.rs @@ -14,7 +14,6 @@ use crate::db::repositories::{ BookExternalLinkRepository, BookMetadataRepository, BookRepository, ExternalLinkRepository, LibraryRepository, PageRepository, SeriesMetadataRepository, SeriesRepository, TaskRepository, }; -use crate::events::EventBroadcaster; use crate::models::{BookStrategy, CalibreStrategyConfig, NumberStrategy, SeriesStrategy}; use crate::parsers::opf; use crate::scanner::analyze_file; @@ -24,6 +23,7 @@ use crate::scanner::strategies::{ }; use crate::tasks::types::TaskType; use crate::utils::normalize_for_search; +use codex_events::EventBroadcaster; use super::types::ScanProgress; @@ -361,7 +361,7 @@ async fn analyze_single_book( && let Some(broadcaster) = event_broadcaster && let Ok(Some(series)) = SeriesRepository::get_by_id(db, book.series_id).await { - use crate::events::{EntityChangeEvent, EntityEvent, EntityType}; + use codex_events::{EntityChangeEvent, EntityEvent, EntityType}; let event = EntityChangeEvent { event: EntityEvent::CoverUpdated { diff --git a/src/scanner/library_scanner.rs b/src/scanner/library_scanner.rs index 136dd1a6..003fa560 100644 --- a/src/scanner/library_scanner.rs +++ b/src/scanner/library_scanner.rs @@ -18,9 +18,9 @@ use crate::db::entities::{books, series}; use crate::db::repositories::{ BookRepository, LibraryRepository, SeriesRepository, TaskRepository, }; -use crate::events::{EventBroadcaster, TaskProgressEvent}; use crate::models::SeriesStrategy; use crate::tasks::types::TaskType; +use codex_events::{EventBroadcaster, TaskProgressEvent}; use super::strategies::{DetectedSeries, create_strategy}; use super::types::{ScanMode, ScanProgress, ScanResult, ScanStatus, ScannerConfig}; diff --git a/src/search/listener.rs b/src/search/listener.rs index 7b7850e6..0be82e2b 100644 --- a/src/search/listener.rs +++ b/src/search/listener.rs @@ -18,7 +18,7 @@ use tokio::sync::broadcast::error::RecvError; use tokio_util::sync::CancellationToken; use tracing::{debug, info, warn}; -use crate::events::{EntityEvent, EventBroadcaster}; +use codex_events::{EntityEvent, EventBroadcaster}; use super::FuzzyIndex; use super::builder::{fetch_book_entry, fetch_series_entry, rebuild_into}; @@ -220,9 +220,9 @@ mod tests { SeriesRepository, }; use crate::db::test_helpers::create_test_db; - use crate::events::EntityChangeEvent; use crate::search::builder::build_from_db; use chrono::Utc; + use codex_events::EntityChangeEvent; use std::time::Duration; use uuid::Uuid; diff --git a/src/search/mod.rs b/src/search/mod.rs index 4783e50a..7981a206 100644 --- a/src/search/mod.rs +++ b/src/search/mod.rs @@ -15,7 +15,7 @@ //! //! Phase 1 exposed build + query. Phase 2 wires in event-driven updates via //! the `listener` module: a Tokio task subscribes to the global -//! [`crate::events::EventBroadcaster`] and translates each entity event into +//! [`codex_events::EventBroadcaster`] and translates each entity event into //! a single-row upsert or remove against the index. pub mod builder; diff --git a/src/services/cleanup_subscriber.rs b/src/services/cleanup_subscriber.rs index 18ddf2ba..c26b3c2c 100644 --- a/src/services/cleanup_subscriber.rs +++ b/src/services/cleanup_subscriber.rs @@ -9,8 +9,8 @@ use tokio::sync::broadcast; use tracing::{debug, error, info, warn}; use crate::db::repositories::TaskRepository; -use crate::events::{EntityChangeEvent, EntityEvent, EventBroadcaster}; use crate::models::task::TaskType; +use codex_events::{EntityChangeEvent, EntityEvent, EventBroadcaster}; /// Service that subscribes to entity events and triggers file cleanup tasks pub struct CleanupEventSubscriber { @@ -190,9 +190,9 @@ impl CleanupEventSubscriber { mod tests { use super::*; use crate::db::test_helpers::create_test_db; - use crate::events::EventBroadcaster; use crate::models::task::TaskType; use chrono::Utc; + use codex_events::EventBroadcaster; use uuid::Uuid; #[tokio::test] diff --git a/src/services/email.rs b/src/services/email.rs index 0c356adf..e745b172 100644 --- a/src/services/email.rs +++ b/src/services/email.rs @@ -1,5 +1,5 @@ -use crate::config::EmailConfig; use anyhow::{Context, Result}; +use codex_config::EmailConfig; use lettre::message::{Mailbox, header::ContentType}; use lettre::transport::smtp::authentication::Credentials; use lettre::{Message, SmtpTransport, Transport}; diff --git a/src/services/file_cleanup.rs b/src/services/file_cleanup.rs index c4c2f989..0bbeb529 100644 --- a/src/services/file_cleanup.rs +++ b/src/services/file_cleanup.rs @@ -12,7 +12,7 @@ use tokio::fs; use tracing::{debug, warn}; use uuid::Uuid; -use crate::config::FilesConfig; +use codex_config::FilesConfig; /// Statistics from a cleanup operation #[derive(Debug, Clone, Default)] diff --git a/src/services/metadata/apply.rs b/src/services/metadata/apply.rs index 39af09f3..5b51f02a 100644 --- a/src/services/metadata/apply.rs +++ b/src/services/metadata/apply.rs @@ -19,9 +19,9 @@ use crate::db::repositories::{ AlternateTitleRepository, ExternalLinkRepository, ExternalRatingRepository, GenreRepository, SeriesExternalIdRepository, SeriesMetadataRepository, TagRepository, }; -use crate::events::EventBroadcaster; use crate::services::ThumbnailService; use crate::services::plugin::PluginSeriesMetadata; +use codex_events::EventBroadcaster; use super::CoverService; diff --git a/src/services/metadata/book_apply.rs b/src/services/metadata/book_apply.rs index 47a60ee8..9b4c52b3 100644 --- a/src/services/metadata/book_apply.rs +++ b/src/services/metadata/book_apply.rs @@ -14,9 +14,9 @@ use uuid::Uuid; use crate::db::entities::book_metadata::Model as BookMetadata; use crate::db::entities::plugins::{Model as Plugin, PluginPermission}; use crate::db::repositories::{BookExternalIdRepository, BookMetadataRepository}; -use crate::events::EventBroadcaster; use crate::services::ThumbnailService; use crate::services::plugin::protocol::PluginBookMetadata; +use codex_events::EventBroadcaster; use super::CoverService; use super::apply::SkippedField; diff --git a/src/services/metadata/cover.rs b/src/services/metadata/cover.rs index 686772bc..c2082edb 100644 --- a/src/services/metadata/cover.rs +++ b/src/services/metadata/cover.rs @@ -12,9 +12,9 @@ use uuid::Uuid; use crate::db::repositories::{ BookCoversRepository, SeriesCoversRepository, SeriesRepository, TaskRepository, }; -use crate::events::{EntityChangeEvent, EntityEvent, EntityType, EventBroadcaster}; use crate::models::task::TaskType; use crate::services::ThumbnailService; +use codex_events::{EntityChangeEvent, EntityEvent, EntityType, EventBroadcaster}; /// Service for downloading and applying cover images to series. pub struct CoverService; diff --git a/src/services/oidc.rs b/src/services/oidc.rs index a3a173e0..d61bb8d0 100644 --- a/src/services/oidc.rs +++ b/src/services/oidc.rs @@ -35,7 +35,7 @@ use std::collections::HashMap; use std::sync::Arc; use tracing::{debug, info, warn}; -use crate::config::{OidcConfig, OidcDefaultRole, OidcProviderConfig}; +use codex_config::{OidcConfig, OidcDefaultRole, OidcProviderConfig}; /// Duration for discovery document cache (1 hour) const DISCOVERY_CACHE_TTL_SECS: i64 = 3600; diff --git a/src/services/pdf_handle_cache_subscriber.rs b/src/services/pdf_handle_cache_subscriber.rs index e782e2c6..e86e1255 100644 --- a/src/services/pdf_handle_cache_subscriber.rs +++ b/src/services/pdf_handle_cache_subscriber.rs @@ -16,8 +16,8 @@ use std::sync::Arc; use tokio::sync::broadcast; use tracing::{debug, error, info, warn}; -use crate::events::{EntityChangeEvent, EntityEvent, EventBroadcaster}; use crate::services::PdfHandleCache; +use codex_events::{EntityChangeEvent, EntityEvent, EventBroadcaster}; /// Background service that listens for book mutation events and drops the /// matching `PdfHandleCache` entry. @@ -88,8 +88,8 @@ impl PdfHandleCacheSubscriber { #[cfg(test)] mod tests { use super::*; - use crate::events::EventBroadcaster; use chrono::Utc; + use codex_events::EventBroadcaster; use std::time::Duration; use uuid::Uuid; diff --git a/src/services/plugin/handle.rs b/src/services/plugin/handle.rs index 3c84d135..afbc9496 100644 --- a/src/services/plugin/handle.rs +++ b/src/services/plugin/handle.rs @@ -333,7 +333,7 @@ impl PluginHandle { // The releases handler emits `ReleaseAnnounced` through the // task-local recording broadcaster set by `crate::tasks::worker` // around the running task — no broadcaster injection needed here. - // See [`crate::events::with_recording_broadcaster`]. + // See [`codex_events::with_recording_broadcaster`]. let manifest_for_ctx = manifest.clone(); let plugin_name = manifest.name.clone(); let release_db = self.release_db.clone(); diff --git a/src/services/plugin/releases_handler.rs b/src/services/plugin/releases_handler.rs index 56e46c92..4518ad45 100644 --- a/src/services/plugin/releases_handler.rs +++ b/src/services/plugin/releases_handler.rs @@ -352,7 +352,7 @@ impl ReleasesRequestHandler { // task (e.g. plugins poking the host on their own initiative), // both calls return None and we silently no-op — there's no task // to attach progress to. - let identity = match crate::events::current_task_identity() { + let identity = match codex_events::current_task_identity() { Some(id_arc) => id_arc, None => { debug!( @@ -365,7 +365,7 @@ impl ReleasesRequestHandler { ); } }; - let broadcaster = match crate::events::current_recording_broadcaster() { + let broadcaster = match codex_events::current_recording_broadcaster() { Some(b) => b, None => { debug!("releases/report_progress: no broadcaster in scope, dropping"); @@ -401,7 +401,7 @@ impl ReleasesRequestHandler { Some(std::time::Instant::now()); } - let event = crate::events::TaskProgressEvent::progress( + let event = codex_events::TaskProgressEvent::progress( identity.task_id, identity.task_type.clone(), params.current, @@ -589,17 +589,22 @@ impl ReleasesRequestHandler { state = %outcome.row.state, "Skipping release_announced emit for non-announced state" ); - } else if let Some(broadcaster) = crate::events::current_recording_broadcaster() { + } else if let Some(broadcaster) = codex_events::current_recording_broadcaster() { let series_title = crate::tasks::handlers::poll_release_source::lookup_series_title( &self.db, outcome.row.series_id, ) .await; - let _ = broadcaster.emit(crate::events::EntityChangeEvent::release_announced( - &outcome.row, - &self.plugin_name, + let _ = broadcaster.emit(codex_events::EntityChangeEvent::release_announced( + outcome.row.id, + outcome.row.series_id, series_title, + outcome.row.source_id, + &self.plugin_name, + outcome.row.chapter, + outcome.row.volume, + outcome.row.language.clone(), )); } else { debug!( @@ -1463,7 +1468,7 @@ mod tests { /// it on dedup. #[tokio::test] async fn record_emits_release_announced_on_insert_only() { - use crate::events::{EntityEvent, EventBroadcaster, with_recording_broadcaster}; + use codex_events::{EntityEvent, EventBroadcaster, with_recording_broadcaster}; let (db, _t) = create_test_db().await; let conn = db.sea_orm_connection(); @@ -1905,7 +1910,7 @@ mod tests { #[tokio::test] async fn report_progress_inside_task_scope_emits_progress_event() { - use crate::events::EventBroadcaster; + use codex_events::EventBroadcaster; let (db, _t) = create_test_db().await; let conn = db.sea_orm_connection(); @@ -1917,7 +1922,7 @@ mod tests { let broadcaster = Arc::new(EventBroadcaster::new(8)); let mut rx = broadcaster.subscribe_tasks(); - let identity = Arc::new(crate::events::TaskIdentity::new( + let identity = Arc::new(codex_events::TaskIdentity::new( Uuid::new_v4(), "poll_release_source", None, @@ -1929,9 +1934,9 @@ mod tests { methods::RELEASES_REPORT_PROGRESS, json!({"current": 3, "total": 10, "message": "Polled 3/10 series"}), ); - let resp = crate::events::with_task_identity( + let resp = codex_events::with_task_identity( identity.clone(), - crate::events::with_recording_broadcaster(broadcaster.clone(), async { + codex_events::with_recording_broadcaster(broadcaster.clone(), async { handler.handle_request(&req).await }), ) @@ -1953,7 +1958,7 @@ mod tests { #[tokio::test] async fn report_progress_rate_limits_back_to_back_emits_but_lets_final_through() { - use crate::events::EventBroadcaster; + use codex_events::EventBroadcaster; let (db, _t) = create_test_db().await; let conn = db.sea_orm_connection(); @@ -1965,7 +1970,7 @@ mod tests { let broadcaster = Arc::new(EventBroadcaster::new(16)); let mut rx = broadcaster.subscribe_tasks(); - let identity = Arc::new(crate::events::TaskIdentity::new( + let identity = Arc::new(codex_events::TaskIdentity::new( Uuid::new_v4(), "poll_release_source", None, @@ -1973,9 +1978,9 @@ mod tests { None, )); - crate::events::with_task_identity( + codex_events::with_task_identity( identity.clone(), - crate::events::with_recording_broadcaster(broadcaster.clone(), async { + codex_events::with_recording_broadcaster(broadcaster.clone(), async { // First emit goes through (last_progress_emit was None). let r1 = handler .handle_request(&make_request( diff --git a/src/services/rate_limiter.rs b/src/services/rate_limiter.rs index a4fb519b..459809db 100644 --- a/src/services/rate_limiter.rs +++ b/src/services/rate_limiter.rs @@ -16,7 +16,7 @@ use tokio_util::sync::CancellationToken; use tracing::{debug, trace}; use uuid::Uuid; -use crate::config::RateLimitConfig; +use codex_config::RateLimitConfig; /// Client identifier for rate limiting #[derive(Clone, Debug, Hash, Eq, PartialEq)] diff --git a/src/services/refresh_token.rs b/src/services/refresh_token.rs index 1c8be1ca..2613f0d3 100644 --- a/src/services/refresh_token.rs +++ b/src/services/refresh_token.rs @@ -211,10 +211,10 @@ fn hex_encode(bytes: &[u8]) -> String { #[cfg(test)] mod tests { use super::*; - use crate::config::{DatabaseConfig, DatabaseType, SQLiteConfig}; use crate::db::Database; use crate::db::entities::users; use crate::db::repositories::UserRepository; + use codex_config::{DatabaseConfig, DatabaseType, SQLiteConfig}; use std::collections::HashMap; use tempfile::TempDir; diff --git a/src/services/release/tracking_toggle.rs b/src/services/release/tracking_toggle.rs index b59e9925..60d5e97a 100644 --- a/src/services/release/tracking_toggle.rs +++ b/src/services/release/tracking_toggle.rs @@ -14,8 +14,8 @@ use std::sync::Arc; use uuid::Uuid; use crate::db::repositories::{SeriesRepository, SeriesTrackingRepository, TrackingUpdate}; -use crate::events::{EntityChangeEvent, EntityEvent, EventBroadcaster}; use crate::services::release::seed::seed_tracking_for_series; +use codex_events::{EntityChangeEvent, EntityEvent, EventBroadcaster}; /// Discrete outcomes for a single-series toggle attempt. /// diff --git a/src/services/task_listener.rs b/src/services/task_listener.rs index 624fe4fb..6a9f714e 100644 --- a/src/services/task_listener.rs +++ b/src/services/task_listener.rs @@ -9,12 +9,12 @@ //! events across process boundaries. use crate::db::repositories::TaskRepository; -use crate::events::{ - EntityChangeEvent, EventBroadcaster, RecordedEvent, TaskProgressEvent, TaskStatus, -}; use anyhow::{Context, Result}; use chrono::TimeZone; use chrono::Utc; +use codex_events::{ + EntityChangeEvent, EventBroadcaster, RecordedEvent, TaskProgressEvent, TaskStatus, +}; use sea_orm::{ DatabaseConnection, SqlxPostgresPoolConnection, sqlx::{PgPool, postgres::PgListener}, diff --git a/src/services/thumbnail.rs b/src/services/thumbnail.rs index 2def74e6..0761d35c 100644 --- a/src/services/thumbnail.rs +++ b/src/services/thumbnail.rs @@ -19,10 +19,10 @@ use tokio_util::io::ReaderStream; use tracing::{debug, error, info, warn}; use uuid::Uuid; -use crate::config::FilesConfig; use crate::db::entities::books; use crate::db::repositories::{BookRepository, SeriesRepository, SettingsRepository}; -use crate::events::{EntityChangeEvent, EntityEvent, EntityType, EventBroadcaster}; +use codex_config::FilesConfig; +use codex_events::{EntityChangeEvent, EntityEvent, EntityType, EventBroadcaster}; // ============================================================================ // Placeholder Thumbnail Generation diff --git a/src/tasks/handlers/analyze_book.rs b/src/tasks/handlers/analyze_book.rs index a9673669..12520869 100644 --- a/src/tasks/handlers/analyze_book.rs +++ b/src/tasks/handlers/analyze_book.rs @@ -6,10 +6,10 @@ use tracing::{error, info, warn}; use crate::db::entities::tasks; use crate::db::repositories::BookRepository; -use crate::events::{EventBroadcaster, TaskProgressEvent}; use crate::scanner::analyze_book; use crate::tasks::handlers::TaskHandler; use crate::tasks::types::TaskResult; +use codex_events::{EventBroadcaster, TaskProgressEvent}; pub struct AnalyzeBookHandler; diff --git a/src/tasks/handlers/analyze_series.rs b/src/tasks/handlers/analyze_series.rs index 92557760..a7c55d52 100644 --- a/src/tasks/handlers/analyze_series.rs +++ b/src/tasks/handlers/analyze_series.rs @@ -6,9 +6,9 @@ use tracing::{error, info}; use crate::db::entities::tasks; use crate::db::repositories::{BookRepository, TaskRepository}; -use crate::events::{EventBroadcaster, TaskProgressEvent}; use crate::tasks::handlers::TaskHandler; use crate::tasks::types::{TaskResult, TaskType}; +use codex_events::{EventBroadcaster, TaskProgressEvent}; pub struct AnalyzeSeriesHandler; diff --git a/src/tasks/handlers/backfill_tracking.rs b/src/tasks/handlers/backfill_tracking.rs index 106d486c..3f3825ad 100644 --- a/src/tasks/handlers/backfill_tracking.rs +++ b/src/tasks/handlers/backfill_tracking.rs @@ -19,10 +19,10 @@ use uuid::Uuid; use crate::db::entities::tasks; use crate::db::repositories::SeriesRepository; -use crate::events::EventBroadcaster; use crate::services::release::seed::{SeedReport, seed_tracking_for_series}; use crate::tasks::handlers::TaskHandler; use crate::tasks::types::TaskResult; +use codex_events::EventBroadcaster; pub struct BackfillTrackingFromMetadataHandler; diff --git a/src/tasks/handlers/bulk_track_for_releases.rs b/src/tasks/handlers/bulk_track_for_releases.rs index 498cc793..24793e20 100644 --- a/src/tasks/handlers/bulk_track_for_releases.rs +++ b/src/tasks/handlers/bulk_track_for_releases.rs @@ -18,12 +18,12 @@ use tracing::{info, warn}; use uuid::Uuid; use crate::db::entities::tasks; -use crate::events::{EventBroadcaster, TaskProgressEvent}; use crate::services::release::tracking_toggle::{ ToggleOutcome, ToggleResult, track_one_series, untrack_one_series, }; use crate::tasks::handlers::TaskHandler; use crate::tasks::types::TaskResult; +use codex_events::{EventBroadcaster, TaskProgressEvent}; pub struct BulkTrackForReleasesHandler; diff --git a/src/tasks/handlers/cleanup_book_files.rs b/src/tasks/handlers/cleanup_book_files.rs index e402f7d5..dded8009 100644 --- a/src/tasks/handlers/cleanup_book_files.rs +++ b/src/tasks/handlers/cleanup_book_files.rs @@ -12,12 +12,12 @@ use std::path::PathBuf; use std::sync::Arc; use tracing::{debug, info, warn}; -use crate::config::FilesConfig; use crate::db::entities::tasks; -use crate::events::EventBroadcaster; use crate::services::{FileCleanupService, ThumbnailService}; use crate::tasks::handlers::TaskHandler; use crate::tasks::types::TaskResult; +use codex_config::FilesConfig; +use codex_events::EventBroadcaster; /// Handler for cleaning up book files after deletion pub struct CleanupBookFilesHandler { diff --git a/src/tasks/handlers/cleanup_orphaned_files.rs b/src/tasks/handlers/cleanup_orphaned_files.rs index a79f207d..a4182a16 100644 --- a/src/tasks/handlers/cleanup_orphaned_files.rs +++ b/src/tasks/handlers/cleanup_orphaned_files.rs @@ -10,13 +10,13 @@ use serde_json::json; use std::sync::Arc; use tracing::{debug, info}; -use crate::config::FilesConfig; use crate::db::entities::tasks; use crate::db::repositories::{BookRepository, SeriesRepository}; -use crate::events::EventBroadcaster; use crate::services::{CleanupStats, FileCleanupService, OrphanedFileType}; use crate::tasks::handlers::TaskHandler; use crate::tasks::types::TaskResult; +use codex_config::FilesConfig; +use codex_events::EventBroadcaster; /// Handler for cleaning up orphaned files pub struct CleanupOrphanedFilesHandler { diff --git a/src/tasks/handlers/cleanup_pdf_cache.rs b/src/tasks/handlers/cleanup_pdf_cache.rs index 485bb2b4..3257b432 100644 --- a/src/tasks/handlers/cleanup_pdf_cache.rs +++ b/src/tasks/handlers/cleanup_pdf_cache.rs @@ -10,10 +10,10 @@ use std::sync::Arc; use tracing::info; use crate::db::entities::tasks; -use crate::events::EventBroadcaster; use crate::services::{PdfPageCache, SettingsService}; use crate::tasks::handlers::TaskHandler; use crate::tasks::types::TaskResult; +use codex_events::EventBroadcaster; /// Handler for cleaning up old PDF cache pages pub struct CleanupPdfCacheHandler { diff --git a/src/tasks/handlers/cleanup_plugin_data.rs b/src/tasks/handlers/cleanup_plugin_data.rs index 4676b0a6..90ee155b 100644 --- a/src/tasks/handlers/cleanup_plugin_data.rs +++ b/src/tasks/handlers/cleanup_plugin_data.rs @@ -13,10 +13,10 @@ use tracing::info; use crate::db::entities::tasks; use crate::db::repositories::UserPluginDataRepository; -use crate::events::EventBroadcaster; use crate::services::user_plugin::OAuthStateManager; use crate::tasks::handlers::TaskHandler; use crate::tasks::types::TaskResult; +use codex_events::EventBroadcaster; /// Handler for cleaning up expired plugin storage data and OAuth state #[derive(Default)] diff --git a/src/tasks/handlers/cleanup_refresh_tokens.rs b/src/tasks/handlers/cleanup_refresh_tokens.rs index 0c00b371..37844ef0 100644 --- a/src/tasks/handlers/cleanup_refresh_tokens.rs +++ b/src/tasks/handlers/cleanup_refresh_tokens.rs @@ -13,9 +13,9 @@ use tracing::info; use crate::db::entities::tasks; use crate::db::repositories::RefreshTokenRepository; -use crate::events::EventBroadcaster; use crate::tasks::handlers::TaskHandler; use crate::tasks::types::TaskResult; +use codex_events::EventBroadcaster; /// Days a revoked refresh-token row sticks around before cleanup deletes it. const REVOKED_GRACE_DAYS: i64 = 30; @@ -57,11 +57,11 @@ impl TaskHandler for CleanupRefreshTokensHandler { #[cfg(test)] mod tests { use super::*; - use crate::config::{DatabaseConfig, DatabaseType, SQLiteConfig}; use crate::db::Database; use crate::db::entities::users; use crate::db::repositories::{NewRefreshToken, UserRepository}; use chrono::{Duration, Utc}; + use codex_config::{DatabaseConfig, DatabaseType, SQLiteConfig}; use std::collections::HashMap; use tempfile::TempDir; use uuid::Uuid; diff --git a/src/tasks/handlers/cleanup_series_exports.rs b/src/tasks/handlers/cleanup_series_exports.rs index 44ea86a8..992d8021 100644 --- a/src/tasks/handlers/cleanup_series_exports.rs +++ b/src/tasks/handlers/cleanup_series_exports.rs @@ -13,11 +13,11 @@ use tracing::{info, warn}; use crate::db::entities::tasks; use crate::db::repositories::SeriesExportRepository; -use crate::events::{EventBroadcaster, TaskProgressEvent}; use crate::services::SettingsService; use crate::services::export_storage::ExportStorage; use crate::tasks::handlers::TaskHandler; use crate::tasks::types::TaskResult; +use codex_events::{EventBroadcaster, TaskProgressEvent}; /// Default global storage cap: 2 GiB const DEFAULT_STORAGE_CAP_BYTES: u64 = 2 * 1024 * 1024 * 1024; diff --git a/src/tasks/handlers/cleanup_series_files.rs b/src/tasks/handlers/cleanup_series_files.rs index 69d8b7da..1001217c 100644 --- a/src/tasks/handlers/cleanup_series_files.rs +++ b/src/tasks/handlers/cleanup_series_files.rs @@ -9,12 +9,12 @@ use serde_json::json; use std::sync::Arc; use tracing::{debug, info, warn}; -use crate::config::FilesConfig; use crate::db::entities::tasks; -use crate::events::EventBroadcaster; use crate::services::FileCleanupService; use crate::tasks::handlers::TaskHandler; use crate::tasks::types::TaskResult; +use codex_config::FilesConfig; +use codex_events::EventBroadcaster; /// Handler for cleaning up series files after deletion pub struct CleanupSeriesFilesHandler { diff --git a/src/tasks/handlers/export_series.rs b/src/tasks/handlers/export_series.rs index d8e6d413..d25cdb84 100644 --- a/src/tasks/handlers/export_series.rs +++ b/src/tasks/handlers/export_series.rs @@ -15,7 +15,6 @@ use uuid::Uuid; use crate::db::entities::tasks; use crate::db::repositories::SeriesExportRepository; -use crate::events::{EventBroadcaster, TaskProgressEvent}; use crate::services::SettingsService; use crate::services::book_export_collector::{self, BookExportField, BookExportRow}; use crate::services::export_storage::ExportStorage; @@ -23,6 +22,7 @@ use crate::services::series_export_collector::{self, ExportField, SeriesExportRo use crate::services::series_export_writer; use crate::tasks::handlers::TaskHandler; use crate::tasks::types::TaskResult; +use codex_events::{EventBroadcaster, TaskProgressEvent}; /// Default maximum number of completed exports kept per user. const DEFAULT_MAX_PER_USER: u64 = 10; diff --git a/src/tasks/handlers/find_duplicates.rs b/src/tasks/handlers/find_duplicates.rs index ff156490..5715909e 100644 --- a/src/tasks/handlers/find_duplicates.rs +++ b/src/tasks/handlers/find_duplicates.rs @@ -7,8 +7,8 @@ use crate::db::entities::tasks; use crate::db::repositories::{ BookDuplicatesRepository, SeriesDuplicatesRepository, SettingsRepository, }; -use crate::events::EventBroadcaster; use crate::tasks::types::TaskResult; +use codex_events::EventBroadcaster; use super::TaskHandler; diff --git a/src/tasks/handlers/generate_series_thumbnail.rs b/src/tasks/handlers/generate_series_thumbnail.rs index baae28af..2cfd1b8b 100644 --- a/src/tasks/handlers/generate_series_thumbnail.rs +++ b/src/tasks/handlers/generate_series_thumbnail.rs @@ -10,10 +10,10 @@ use tracing::{debug, info, warn}; use crate::db::entities::tasks; use crate::db::repositories::{BookRepository, SeriesCoversRepository, SeriesRepository}; -use crate::events::{EntityChangeEvent, EntityEvent, EntityType, EventBroadcaster}; use crate::services::ThumbnailService; use crate::tasks::handlers::TaskHandler; use crate::tasks::types::TaskResult; +use codex_events::{EntityChangeEvent, EntityEvent, EntityType, EventBroadcaster}; pub struct GenerateSeriesThumbnailHandler { thumbnail_service: Arc<ThumbnailService>, diff --git a/src/tasks/handlers/generate_series_thumbnails.rs b/src/tasks/handlers/generate_series_thumbnails.rs index 8feb962e..eb4c0915 100644 --- a/src/tasks/handlers/generate_series_thumbnails.rs +++ b/src/tasks/handlers/generate_series_thumbnails.rs @@ -11,10 +11,10 @@ use tracing::{debug, info, warn}; use crate::db::entities::tasks; use crate::db::repositories::{SeriesRepository, TaskRepository}; -use crate::events::{EventBroadcaster, TaskProgressEvent}; use crate::services::ThumbnailService; use crate::tasks::handlers::TaskHandler; use crate::tasks::types::{TaskResult, TaskType}; +use codex_events::{EventBroadcaster, TaskProgressEvent}; pub struct GenerateSeriesThumbnailsHandler { thumbnail_service: Arc<ThumbnailService>, diff --git a/src/tasks/handlers/generate_thumbnail.rs b/src/tasks/handlers/generate_thumbnail.rs index e3dda236..85387e3c 100644 --- a/src/tasks/handlers/generate_thumbnail.rs +++ b/src/tasks/handlers/generate_thumbnail.rs @@ -6,10 +6,10 @@ use tracing::{debug, error, info, warn}; use crate::db::entities::book_error::{BookError, BookErrorType}; use crate::db::entities::tasks; use crate::db::repositories::{BookRepository, SeriesRepository, TaskRepository}; -use crate::events::{EntityChangeEvent, EntityEvent, EntityType, EventBroadcaster}; use crate::services::ThumbnailService; use crate::tasks::handlers::TaskHandler; use crate::tasks::types::{TaskResult, TaskType}; +use codex_events::{EntityChangeEvent, EntityEvent, EntityType, EventBroadcaster}; pub struct GenerateThumbnailHandler { thumbnail_service: Arc<ThumbnailService>, diff --git a/src/tasks/handlers/generate_thumbnails.rs b/src/tasks/handlers/generate_thumbnails.rs index cc3ea70b..bd2e9233 100644 --- a/src/tasks/handlers/generate_thumbnails.rs +++ b/src/tasks/handlers/generate_thumbnails.rs @@ -5,10 +5,10 @@ use tracing::{debug, info, warn}; use crate::db::entities::tasks; use crate::db::repositories::{BookRepository, TaskRepository}; -use crate::events::{EventBroadcaster, TaskProgressEvent}; use crate::services::ThumbnailService; use crate::tasks::handlers::TaskHandler; use crate::tasks::types::{TaskResult, TaskType}; +use codex_events::{EventBroadcaster, TaskProgressEvent}; pub struct GenerateThumbnailsHandler { thumbnail_service: Arc<ThumbnailService>, diff --git a/src/tasks/handlers/mod.rs b/src/tasks/handlers/mod.rs index e9919d6f..1c865acd 100644 --- a/src/tasks/handlers/mod.rs +++ b/src/tasks/handlers/mod.rs @@ -3,8 +3,8 @@ use sea_orm::DatabaseConnection; use std::sync::Arc; use crate::db::entities::tasks; -use crate::events::EventBroadcaster; use crate::tasks::types::TaskResult; +use codex_events::EventBroadcaster; pub mod analyze_book; pub mod analyze_series; diff --git a/src/tasks/handlers/plugin_auto_match.rs b/src/tasks/handlers/plugin_auto_match.rs index 190d555b..603b825d 100644 --- a/src/tasks/handlers/plugin_auto_match.rs +++ b/src/tasks/handlers/plugin_auto_match.rs @@ -24,7 +24,6 @@ use crate::db::repositories::{ BookExternalIdRepository, BookMetadataRepository, BookRepository, LibraryRepository, PluginsRepository, SeriesExternalIdRepository, SeriesMetadataRepository, SeriesRepository, }; -use crate::events::{EntityChangeEvent, EntityEvent, EventBroadcaster, TaskProgressEvent}; use crate::services::ThumbnailService; use crate::services::metadata::preprocessing::{ AutoMatchConditions, PreprocessingRule, SeriesContext, SeriesContextBuilder, apply_rules, @@ -40,6 +39,7 @@ use crate::services::plugin::{PluginManager, PluginManagerError}; use crate::services::settings::SettingsService; use crate::tasks::handlers::TaskHandler; use crate::tasks::types::TaskResult; +use codex_events::{EntityChangeEvent, EntityEvent, EventBroadcaster, TaskProgressEvent}; /// Settings key for the auto-match confidence threshold const SETTING_AUTO_MATCH_CONFIDENCE_THRESHOLD: &str = "plugins.auto_match_confidence_threshold"; diff --git a/src/tasks/handlers/poll_release_source.rs b/src/tasks/handlers/poll_release_source.rs index 52f1e132..99ddab6a 100644 --- a/src/tasks/handlers/poll_release_source.rs +++ b/src/tasks/handlers/poll_release_source.rs @@ -40,7 +40,6 @@ use crate::db::repositories::{ NewReleaseEntry, PluginsRepository, ReleaseLedgerRepository, ReleaseSourceRepository, SeriesRepository, SeriesTrackingRepository, }; -use crate::events::{EntityChangeEvent, EventBroadcaster}; use crate::services::SettingsService; use crate::services::plugin::PluginManager; use crate::services::plugin::handle::PluginError; @@ -50,6 +49,7 @@ use crate::services::release::backoff::{HostBackoff, is_backoff_status}; use crate::services::release::matcher::{evaluate, resolve_threshold}; use crate::tasks::handlers::TaskHandler; use crate::tasks::types::TaskResult; +use codex_events::{EntityChangeEvent, EventBroadcaster}; /// Default plugin task timeout in seconds (5 minutes — same as user_plugin_sync). const DEFAULT_TASK_TIMEOUT_SECS: u64 = 300; @@ -638,9 +638,14 @@ pub(crate) fn emit_release_announced( series_title: String, ) { let _ = broadcaster.emit(EntityChangeEvent::release_announced( - row, - plugin_id, + row.id, + row.series_id, series_title, + row.source_id, + plugin_id, + row.chapter, + row.volume, + row.language.clone(), )); } @@ -748,7 +753,7 @@ mod tests { }; use crate::db::test_helpers::create_test_db; - use crate::events::EntityEvent; + use codex_events::EntityEvent; /// `emit_release_announced` produces a `ReleaseAnnounced` event whose /// fields mirror the ledger row and the source's plugin id. diff --git a/src/tasks/handlers/purge_deleted.rs b/src/tasks/handlers/purge_deleted.rs index 4363ae4c..35b81996 100644 --- a/src/tasks/handlers/purge_deleted.rs +++ b/src/tasks/handlers/purge_deleted.rs @@ -6,9 +6,9 @@ use tracing::{error, info}; use crate::db::entities::tasks; use crate::db::repositories::BookRepository; -use crate::events::EventBroadcaster; use crate::tasks::handlers::TaskHandler; use crate::tasks::types::TaskResult; +use codex_events::EventBroadcaster; pub struct PurgeDeletedHandler; diff --git a/src/tasks/handlers/refresh_library_metadata.rs b/src/tasks/handlers/refresh_library_metadata.rs index 36eee0e2..9442ffbe 100644 --- a/src/tasks/handlers/refresh_library_metadata.rs +++ b/src/tasks/handlers/refresh_library_metadata.rs @@ -25,7 +25,6 @@ use crate::db::repositories::{ LibraryJobRepository, LibraryRepository, PluginsRepository, RecordRunStatus, SeriesExternalIdRepository, SeriesMetadataRepository, SeriesRepository, }; -use crate::events::{EntityChangeEvent, EntityEvent, EventBroadcaster, TaskProgressEvent}; use crate::services::ThumbnailService; use crate::services::library_jobs::{LibraryJobConfig, RefreshScope, parse_job_config}; use crate::services::metadata::refresh_planner::{ @@ -37,6 +36,7 @@ use crate::services::plugin::PluginManager; use crate::services::plugin::protocol::{MetadataGetParams, MetadataMatchParams}; use crate::tasks::handlers::TaskHandler; use crate::tasks::types::TaskResult; +use codex_events::{EntityChangeEvent, EntityEvent, EventBroadcaster, TaskProgressEvent}; /// Soft cap to keep one job's refresh from monopolizing the worker. const MAX_CONCURRENCY_HARD_CAP: usize = 16; diff --git a/src/tasks/handlers/renumber_series.rs b/src/tasks/handlers/renumber_series.rs index 07bf722b..9b85d40e 100644 --- a/src/tasks/handlers/renumber_series.rs +++ b/src/tasks/handlers/renumber_series.rs @@ -13,9 +13,9 @@ use uuid::Uuid; use crate::db::entities::tasks; use crate::db::repositories::{SeriesRepository, TaskRepository}; -use crate::events::{EntityChangeEvent, EntityEvent, EventBroadcaster, TaskProgressEvent}; use crate::tasks::handlers::TaskHandler; use crate::tasks::types::{TaskResult, TaskType}; +use codex_events::{EntityChangeEvent, EntityEvent, EventBroadcaster, TaskProgressEvent}; // ============================================================================= // RenumberSeries Handler (Single Series) diff --git a/src/tasks/handlers/reprocess_series_titles.rs b/src/tasks/handlers/reprocess_series_titles.rs index 70915460..4bf8f41d 100644 --- a/src/tasks/handlers/reprocess_series_titles.rs +++ b/src/tasks/handlers/reprocess_series_titles.rs @@ -14,10 +14,10 @@ use crate::db::entities::{series_metadata, tasks}; use crate::db::repositories::{ LibraryRepository, SeriesMetadataRepository, SeriesRepository, TaskRepository, }; -use crate::events::{EntityChangeEvent, EntityEvent, EventBroadcaster, TaskProgressEvent}; use crate::services::metadata::preprocessing::apply_rules; use crate::tasks::handlers::TaskHandler; use crate::tasks::types::{TaskResult, TaskType}; +use codex_events::{EntityChangeEvent, EntityEvent, EventBroadcaster, TaskProgressEvent}; // ============================================================================= // ReprocessSeriesTitle Handler (Single Series) diff --git a/src/tasks/handlers/scan_library.rs b/src/tasks/handlers/scan_library.rs index ab3a8d08..3aa26d42 100644 --- a/src/tasks/handlers/scan_library.rs +++ b/src/tasks/handlers/scan_library.rs @@ -8,12 +8,12 @@ use crate::db::entities::tasks; use crate::db::repositories::{ BookRepository, LibraryRepository, PluginsRepository, SeriesRepository, TaskRepository, }; -use crate::events::EventBroadcaster; use crate::scanner::{ScanMode, ScanningConfig, scan_library}; use crate::services::plugin::protocol::PluginScope; use crate::services::settings::SettingsService; use crate::tasks::handlers::TaskHandler; use crate::tasks::types::{TaskResult, TaskType}; +use codex_events::EventBroadcaster; /// Settings key for enabling post-scan auto-match const SETTING_POST_SCAN_AUTO_MATCH_ENABLED: &str = "plugins.post_scan_auto_match_enabled"; diff --git a/src/tasks/handlers/user_plugin_recommendation_dismiss.rs b/src/tasks/handlers/user_plugin_recommendation_dismiss.rs index 9829204b..deb23af0 100644 --- a/src/tasks/handlers/user_plugin_recommendation_dismiss.rs +++ b/src/tasks/handlers/user_plugin_recommendation_dismiss.rs @@ -12,7 +12,6 @@ use tracing::{debug, info, warn}; use uuid::Uuid; use crate::db::entities::tasks; -use crate::events::EventBroadcaster; use crate::services::SettingsService; use crate::services::plugin::PluginManager; use crate::services::plugin::protocol::methods; @@ -21,6 +20,7 @@ use crate::services::plugin::recommendations::{ }; use crate::tasks::handlers::TaskHandler; use crate::tasks::types::TaskResult; +use codex_events::EventBroadcaster; /// Default plugin task timeout in seconds (5 minutes) const DEFAULT_TASK_TIMEOUT_SECS: u64 = 300; diff --git a/src/tasks/handlers/user_plugin_recommendations.rs b/src/tasks/handlers/user_plugin_recommendations.rs index 48d12ee6..b91198b3 100644 --- a/src/tasks/handlers/user_plugin_recommendations.rs +++ b/src/tasks/handlers/user_plugin_recommendations.rs @@ -16,7 +16,6 @@ use uuid::Uuid; use crate::db::entities::tasks; use crate::db::repositories::{PluginsRepository, UserPluginDataRepository, UserPluginsRepository}; -use crate::events::EventBroadcaster; use crate::services::SettingsService; use crate::services::plugin::PluginManager; use crate::services::plugin::library::build_user_library; @@ -28,6 +27,7 @@ use crate::services::plugin::recommendations::{ }; use crate::tasks::handlers::TaskHandler; use crate::tasks::types::TaskResult; +use codex_events::EventBroadcaster; /// Default plugin task timeout in seconds (5 minutes) const DEFAULT_TASK_TIMEOUT_SECS: u64 = 300; @@ -204,7 +204,7 @@ fn emit_phase( Some(d) => format!("{}: {}", phase.1, d), None => phase.1.to_string(), }; - let _ = b.emit_task(crate::events::TaskProgressEvent::progress( + let _ = b.emit_task(codex_events::TaskProgressEvent::progress( task.id, "user_plugin_recommendations", phase.0, diff --git a/src/tasks/handlers/user_plugin_sync/mod.rs b/src/tasks/handlers/user_plugin_sync/mod.rs index fd0b9916..c805b992 100644 --- a/src/tasks/handlers/user_plugin_sync/mod.rs +++ b/src/tasks/handlers/user_plugin_sync/mod.rs @@ -27,7 +27,6 @@ use uuid::Uuid; use crate::db::entities::tasks; use crate::db::repositories::{UserPluginDataRepository, UserPluginsRepository}; -use crate::events::{EventBroadcaster, TaskProgressEvent}; use crate::services::SettingsService; use crate::services::plugin::PluginManager; use crate::services::plugin::protocol::methods; @@ -36,6 +35,7 @@ use crate::services::plugin::sync::{ }; use crate::tasks::handlers::TaskHandler; use crate::tasks::types::TaskResult; +use codex_events::{EventBroadcaster, TaskProgressEvent}; pub(crate) use settings::CodexSyncSettings; diff --git a/src/tasks/worker.rs b/src/tasks/worker.rs index f31cfd82..b3374078 100644 --- a/src/tasks/worker.rs +++ b/src/tasks/worker.rs @@ -16,9 +16,7 @@ use tokio::time::sleep; use tracing::{debug, error, info, warn}; use uuid::Uuid; -use crate::config::FilesConfig; use crate::db::repositories::TaskRepository; -use crate::events::{EventBroadcaster, RecordedEvent, TaskProgressEvent}; use crate::services::PdfPageCache; use crate::services::export_storage::ExportStorage; use crate::services::plugin::PluginManager; @@ -38,6 +36,8 @@ use crate::tasks::handlers::{ UserPluginRecommendationDismissHandler, UserPluginRecommendationsHandler, UserPluginSyncHandler, }; +use codex_config::FilesConfig; +use codex_events::{EventBroadcaster, RecordedEvent, TaskProgressEvent}; /// RAII guard that increments the OTel in-flight task gauge on creation and /// decrements it on drop. Used by `process_next_task` to track currently- @@ -661,7 +661,7 @@ impl TaskWorker { // task-local context. Used by `releases/report_progress` to // construct a `TaskProgressEvent` (which needs the task id/type) // and to rate-limit emits. - let task_identity = Arc::new(crate::events::TaskIdentity::new( + let task_identity = Arc::new(codex_events::TaskIdentity::new( task.id, task.task_type.clone(), task.library_id, @@ -703,9 +703,9 @@ impl TaskWorker { // via reverse-RPC would have no recording context and their // events would never replay. let result = tracing::Instrument::instrument( - crate::events::with_task_identity( + codex_events::with_task_identity( task_identity.clone(), - crate::events::with_recording_broadcaster( + codex_events::with_recording_broadcaster( recording_broadcaster.clone(), handler.handle(&task, &self.db, Some(&recording_broadcaster)), ), @@ -759,9 +759,9 @@ impl TaskWorker { // process mode), so emits flow straight to live SSE subscribers. let result = if let Some(ref shared) = task_broadcaster { tracing::Instrument::instrument( - crate::events::with_task_identity( + codex_events::with_task_identity( task_identity.clone(), - crate::events::with_recording_broadcaster( + codex_events::with_recording_broadcaster( shared.clone(), handler.handle(&task, &self.db, task_broadcaster.as_ref()), ), @@ -771,7 +771,7 @@ impl TaskWorker { .await } else { tracing::Instrument::instrument( - crate::events::with_task_identity( + codex_events::with_task_identity( task_identity.clone(), handler.handle(&task, &self.db, task_broadcaster.as_ref()), ), @@ -1017,9 +1017,9 @@ mod tests { use super::*; use crate::db::repositories::TaskRepository; use crate::db::test_helpers::create_test_db; - use crate::events::{EntityChangeEvent, EntityEvent, EntityType}; use crate::tasks::handlers::TaskHandler; use crate::tasks::types::{TaskResult, TaskType}; + use codex_events::{EntityChangeEvent, EntityEvent, EntityType}; /// Stub handler that returns whatever `TaskResult` it was constructed with. /// Used to drive the worker through specific result branches without From 1f2584c864526a57817b3ecab2638b55f72c717f Mon Sep 17 00:00:00 2001 From: Sylvain Cau <ashdevfr@gmail.com> Date: Sat, 23 May 2026 15:58:38 -0700 Subject: [PATCH 03/14] refactor(workspace): extract codex-models, codex-utils, codex-parsers crates Splits three more leaf subsystems out of the monolithic codex crate into sibling workspace members alongside codex-config and codex-events. - codex-models: pure-leaf shared types (permissions, sort, filter, plugin, preprocessing, release, strategies, task). No internal deps. - codex-utils: hashing, password, jwt, cron, deadline, json, natural sort, search, serde adapters, credential encryption. Depends on codex-models (jwt -> UserRole). - codex-parsers: CBZ/CBR/EPUB/PDF parsers, ComicInfo/OPF/series.json metadata, image utilities. Depends on codex-utils for CodexError and hash_file. Owns the `rar` feature; the root crate's `rar` feature now forwards to codex-parsers/rar. Workspace-internal crates are now declared in [workspace.dependencies] so members reference each other via { workspace = true } from one source of truth instead of inline path = "...". The root codex crate keeps `pub use codex_{models,utils,parsers} as {models,utils,parsers}` so integration tests that import via codex::* continue to resolve without changes. EpubParser::find_root_file and parse_opf were promoted from pub(crate) to pub since they're called from the still-root Komga manifest handler. Cold and warm rebuild times stay flat-to-slightly-improved; the workspace mechanics work but the dominant compile cost is still the root crate which holds api, db, services, scanner, tasks, scheduler, and search. --- Cargo.lock | 70 ++++++++++++++++++- Cargo.toml | 36 +++++++--- crates/codex-models/Cargo.toml | 17 +++++ .../codex-models/src}/filter.rs | 0 .../mod.rs => crates/codex-models/src/lib.rs | 8 +-- .../codex-models/src}/permissions.rs | 0 .../codex-models/src}/plugin.rs | 2 +- .../codex-models/src}/preprocessing.rs | 0 .../codex-models/src}/release.rs | 6 +- .../codex-models/src}/sort.rs | 0 .../codex-models/src}/strategies.rs | 0 .../codex-models/src}/task.rs | 0 crates/codex-parsers/Cargo.toml | 47 +++++++++++++ .../codex-parsers/src}/cbr/mod.rs | 0 .../codex-parsers/src}/cbr/parser.rs | 10 +-- .../codex-parsers/src}/cbz/mod.rs | 0 .../codex-parsers/src}/cbz/parser.rs | 10 +-- .../codex-parsers/src}/comic_info.rs | 2 +- .../codex-parsers/src}/epub/mod.rs | 0 .../codex-parsers/src}/epub/parser.rs | 22 +++--- .../codex-parsers/src}/image_utils.rs | 4 +- .../codex-parsers/src}/isbn_utils.rs | 0 .../mod.rs => crates/codex-parsers/src/lib.rs | 6 ++ .../codex-parsers/src}/metadata.rs | 0 .../codex-parsers/src}/opf.rs | 6 +- .../codex-parsers/src}/pdf/mod.rs | 0 .../codex-parsers/src}/pdf/parser.rs | 10 +-- .../codex-parsers/src}/pdf/renderer.rs | 0 .../codex-parsers/src}/series_json.rs | 2 +- .../codex-parsers/src}/traits.rs | 4 +- crates/codex-utils/Cargo.toml | 38 ++++++++++ .../codex-utils/src}/credential_encryption.rs | 0 {src/utils => crates/codex-utils/src}/cron.rs | 0 .../codex-utils/src}/deadline.rs | 0 .../utils => crates/codex-utils/src}/error.rs | 0 .../codex-utils/src}/hasher.rs | 0 {src/utils => crates/codex-utils/src}/json.rs | 0 {src/utils => crates/codex-utils/src}/jwt.rs | 2 +- .../mod.rs => crates/codex-utils/src/lib.rs | 6 ++ .../codex-utils/src}/natural_sort.rs | 0 .../codex-utils/src}/password.rs | 0 .../codex-utils/src}/search.rs | 0 .../utils => crates/codex-utils/src}/serde.rs | 0 src/api/docs.rs | 16 ++--- src/api/extractors/auth.rs | 2 +- src/api/permissions.rs | 4 +- src/api/routes/komga/dto/mod.rs | 2 +- src/api/routes/komga/handlers/libraries.rs | 8 +-- src/api/routes/komga/handlers/manifest.rs | 2 +- .../routes/komga/handlers/read_progress.rs | 4 +- src/api/routes/koreader/handlers/sync.rs | 2 +- src/api/routes/v1/dto/book.rs | 4 +- src/api/routes/v1/dto/common.rs | 2 +- src/api/routes/v1/dto/filter.rs | 4 +- src/api/routes/v1/dto/library.rs | 2 +- src/api/routes/v1/dto/scan.rs | 4 +- src/api/routes/v1/dto/series.rs | 4 +- src/api/routes/v1/handlers/api_keys.rs | 2 +- src/api/routes/v1/handlers/auth.rs | 2 +- src/api/routes/v1/handlers/books.rs | 10 +-- src/api/routes/v1/handlers/bulk_metadata.rs | 8 +-- src/api/routes/v1/handlers/libraries.rs | 2 +- src/api/routes/v1/handlers/pages.rs | 20 +++--- src/api/routes/v1/handlers/read_progress.rs | 14 ++-- src/api/routes/v1/handlers/series.rs | 10 +-- src/api/routes/v1/handlers/setup.rs | 2 +- src/api/routes/v1/handlers/users.rs | 2 +- src/commands/scan.rs | 2 +- src/commands/seed.rs | 4 +- src/commands/serve.rs | 4 +- src/commands/worker.rs | 2 +- src/db/entities/plugins.rs | 8 +-- src/db/entities/users.rs | 2 +- src/db/mod.rs | 2 +- src/db/repositories/book.rs | 12 ++-- src/db/repositories/library.rs | 12 ++-- src/db/repositories/metadata.rs | 2 +- src/db/repositories/plugin_failures.rs | 2 +- src/db/repositories/plugins.rs | 12 ++-- src/db/repositories/read_progress.rs | 4 +- src/db/repositories/release_ledger.rs | 2 +- src/db/repositories/release_sources.rs | 2 +- src/db/repositories/series.rs | 10 +-- src/db/repositories/series_metadata.rs | 2 +- src/db/repositories/task.rs | 4 +- src/db/repositories/user_plugins.rs | 2 +- src/db/repositories/user_preferences.rs | 2 +- src/lib.rs | 11 +-- src/main.rs | 3 - src/scanner/analyzer.rs | 26 +++---- src/scanner/analyzer_queue.rs | 26 +++---- src/scanner/detector.rs | 2 +- src/scanner/library_scanner.rs | 4 +- src/scanner/strategies/book/custom.rs | 2 +- src/scanner/strategies/book/filename.rs | 2 +- src/scanner/strategies/book/metadata_first.rs | 2 +- src/scanner/strategies/book/mod.rs | 2 +- src/scanner/strategies/book/series_name.rs | 2 +- src/scanner/strategies/book/smart.rs | 2 +- src/scanner/strategies/number/file_order.rs | 2 +- src/scanner/strategies/number/filename.rs | 2 +- src/scanner/strategies/number/metadata.rs | 2 +- src/scanner/strategies/number/mod.rs | 2 +- src/scanner/strategies/number/smart.rs | 2 +- src/scanner/strategies/series/calibre.rs | 4 +- src/scanner/strategies/series/custom.rs | 2 +- src/scanner/strategies/series/flat.rs | 2 +- src/scanner/strategies/series/mod.rs | 2 +- .../strategies/series/publisher_hierarchy.rs | 2 +- .../strategies/series/series_volume.rs | 2 +- .../series/series_volume_chapter.rs | 2 +- src/scheduler/mod.rs | 4 +- src/scheduler/release_sources.rs | 2 +- src/search/index.rs | 2 +- src/services/auth_tracking.rs | 2 +- src/services/cleanup_subscriber.rs | 4 +- src/services/filter.rs | 6 +- src/services/library_jobs/validation.rs | 2 +- src/services/metadata/cover.rs | 6 +- src/services/metadata/preprocessing/types.rs | 4 +- src/services/mod.rs | 4 +- src/services/plugin/protocol.rs | 4 +- src/services/read_progress.rs | 4 +- src/services/release/auto_ignore.rs | 4 +- src/services/release/candidate.rs | 4 +- src/services/release/schedule.rs | 2 +- src/services/thumbnail.rs | 8 +-- src/tasks/error.rs | 4 +- .../handlers/generate_series_thumbnail.rs | 8 +-- src/tasks/types.rs | 4 +- 130 files changed, 458 insertions(+), 262 deletions(-) create mode 100644 crates/codex-models/Cargo.toml rename {src/models => crates/codex-models/src}/filter.rs (100%) rename src/models/mod.rs => crates/codex-models/src/lib.rs (61%) rename {src/models => crates/codex-models/src}/permissions.rs (100%) rename {src/models => crates/codex-models/src}/plugin.rs (99%) rename {src/models => crates/codex-models/src}/preprocessing.rs (100%) rename {src/models => crates/codex-models/src}/release.rs (94%) rename {src/models => crates/codex-models/src}/sort.rs (100%) rename {src/models => crates/codex-models/src}/strategies.rs (100%) rename {src/models => crates/codex-models/src}/task.rs (100%) create mode 100644 crates/codex-parsers/Cargo.toml rename {src/parsers => crates/codex-parsers/src}/cbr/mod.rs (100%) rename {src/parsers => crates/codex-parsers/src}/cbr/parser.rs (97%) rename {src/parsers => crates/codex-parsers/src}/cbz/mod.rs (100%) rename {src/parsers => crates/codex-parsers/src}/cbz/parser.rs (96%) rename {src/parsers => crates/codex-parsers/src}/comic_info.rs (99%) rename {src/parsers => crates/codex-parsers/src}/epub/mod.rs (100%) rename {src/parsers => crates/codex-parsers/src}/epub/parser.rs (98%) rename {src/parsers => crates/codex-parsers/src}/image_utils.rs (99%) rename {src/parsers => crates/codex-parsers/src}/isbn_utils.rs (100%) rename src/parsers/mod.rs => crates/codex-parsers/src/lib.rs (54%) rename {src/parsers => crates/codex-parsers/src}/metadata.rs (100%) rename {src/parsers => crates/codex-parsers/src}/opf.rs (99%) rename {src/parsers => crates/codex-parsers/src}/pdf/mod.rs (100%) rename {src/parsers => crates/codex-parsers/src}/pdf/parser.rs (99%) rename {src/parsers => crates/codex-parsers/src}/pdf/renderer.rs (100%) rename {src/parsers => crates/codex-parsers/src}/series_json.rs (99%) rename {src/parsers => crates/codex-parsers/src}/traits.rs (88%) create mode 100644 crates/codex-utils/Cargo.toml rename {src/utils => crates/codex-utils/src}/credential_encryption.rs (100%) rename {src/utils => crates/codex-utils/src}/cron.rs (100%) rename {src/utils => crates/codex-utils/src}/deadline.rs (100%) rename {src/utils => crates/codex-utils/src}/error.rs (100%) rename {src/utils => crates/codex-utils/src}/hasher.rs (100%) rename {src/utils => crates/codex-utils/src}/json.rs (100%) rename {src/utils => crates/codex-utils/src}/jwt.rs (99%) rename src/utils/mod.rs => crates/codex-utils/src/lib.rs (71%) rename {src/utils => crates/codex-utils/src}/natural_sort.rs (100%) rename {src/utils => crates/codex-utils/src}/password.rs (100%) rename {src/utils => crates/codex-utils/src}/search.rs (100%) rename {src/utils => crates/codex-utils/src}/serde.rs (100%) diff --git a/Cargo.lock b/Cargo.lock index 05ea4d1d..2604dce5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -823,6 +823,9 @@ dependencies = [ "clap", "codex-config", "codex-events", + "codex-models", + "codex-parsers", + "codex-utils", "cron", "csv", "dashmap", @@ -834,7 +837,6 @@ dependencies = [ "httpdate", "hyper", "image", - "infer", "jsonwebtoken", "jxl-oxide", "lazy_static", @@ -883,7 +885,6 @@ dependencies = [ "tracing-subscriber", "tracing-test", "unicode-normalization", - "unrar", "urlencoding", "utoipa", "utoipa-scalar", @@ -916,6 +917,71 @@ dependencies = [ "uuid", ] +[[package]] +name = "codex-models" +version = "0.0.0" +dependencies = [ + "chrono", + "lazy_static", + "serde", + "serde_json", + "utoipa", + "uuid", +] + +[[package]] +name = "codex-parsers" +version = "0.0.0" +dependencies = [ + "anyhow", + "chrono", + "codex-utils", + "image", + "infer", + "jxl-oxide", + "lopdf", + "pdfium-render", + "quick-xml", + "regex", + "resvg", + "serde", + "serde_json", + "tempfile", + "tracing", + "unrar", + "urlencoding", + "zip", +] + +[[package]] +name = "codex-utils" +version = "0.0.0" +dependencies = [ + "aes-gcm", + "anyhow", + "argon2", + "base64 0.22.1", + "chrono", + "chrono-tz", + "codex-models", + "cron", + "image", + "jsonwebtoken", + "md-5", + "quick-xml", + "rand 0.10.0", + "serde", + "serde_json", + "serial_test", + "sha2", + "tempfile", + "thiserror 2.0.18", + "tokio", + "unicode-normalization", + "uuid", + "zip", +] + [[package]] name = "color_quant" version = "1.1.0" diff --git a/Cargo.toml b/Cargo.toml index c8e7a1db..edbd4283 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,7 +14,7 @@ path = "src/main.rs" [features] default = ["rar", "observability"] -rar = ["dep:unrar"] +rar = ["codex-parsers/rar"] embed-frontend = [] observability = [ "dep:opentelemetry", @@ -28,7 +28,15 @@ observability = [ ] [workspace] -members = [".", "migration", "crates/codex-config", "crates/codex-events"] +members = [ + ".", + "migration", + "crates/codex-config", + "crates/codex-events", + "crates/codex-models", + "crates/codex-utils", + "crates/codex-parsers", +] # Shared dependencies inherited by workspace members. Only deps that are # actually consumed by more than one crate live here; the others stay inline in @@ -48,6 +56,14 @@ utoipa = { version = "5.0", features = [ ] } uuid = { version = "1.0", features = ["v4", "serde"] } +# Workspace-internal crates. Declaring them here keeps cross-crate path edges +# in one place so members reference each other via `{ workspace = true }`. +codex-config = { path = "crates/codex-config" } +codex-events = { path = "crates/codex-events" } +codex-models = { path = "crates/codex-models" } +codex-parsers = { path = "crates/codex-parsers", default-features = false } +codex-utils = { path = "crates/codex-utils" } + # Shared dev-dependencies tempfile = "3.13" serial_test = "3.2" @@ -66,17 +82,14 @@ csv = "1.3" # Archive formats zip = "8.1" -unrar = { version = "0.5", optional = true } -# PDF parsing -lopdf = "0.39" +# PDF runtime handle cache (pdfium_render types are re-exposed by services) pdfium-render = { version = "0.8", features = ["sync"] } # Image processing image = { version = "0.25", features = ["avif"] } resvg = "0.47" jxl-oxide = "0.12" -infer = "0.19" # Hashing sha2 = "0.10" @@ -129,8 +142,11 @@ sea-orm-migration = { version = "1.1", features = [ "sqlx-sqlite", ] } migration = { path = "migration" } -codex-config = { path = "crates/codex-config" } -codex-events = { path = "crates/codex-events" } +codex-config = { workspace = true } +codex-events = { workspace = true } +codex-models = { workspace = true } +codex-parsers = { workspace = true } +codex-utils = { workspace = true } tokio = { workspace = true } uuid = { workspace = true } @@ -227,6 +243,10 @@ http-body-util = "0.1" hyper = { version = "1.0", features = ["full"] } serial_test = { workspace = true } tracing-test = "0.2" +# Used by tests/common/files.rs to mint PDF fixtures. The runtime PDF +# rendering path lives in codex-parsers; tests reach for lopdf directly to +# craft byte-level inputs. +lopdf = "0.39" # Enable the SDK's `testing` feature for the in-memory metric exporter used # in observability::metrics tests. Dev-only; no production impact. opentelemetry_sdk = { version = "0.32", features = ["rt-tokio", "trace", "metrics", "testing"] } diff --git a/crates/codex-models/Cargo.toml b/crates/codex-models/Cargo.toml new file mode 100644 index 00000000..786471ee --- /dev/null +++ b/crates/codex-models/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "codex-models" +version = "0.0.0" +edition = "2024" +publish = false + +[lib] +name = "codex_models" +path = "src/lib.rs" + +[dependencies] +chrono = { workspace = true } +serde = { workspace = true } +serde_json = "1.0" +utoipa = { workspace = true } +uuid = { workspace = true } +lazy_static = "1.4" diff --git a/src/models/filter.rs b/crates/codex-models/src/filter.rs similarity index 100% rename from src/models/filter.rs rename to crates/codex-models/src/filter.rs diff --git a/src/models/mod.rs b/crates/codex-models/src/lib.rs similarity index 61% rename from src/models/mod.rs rename to crates/codex-models/src/lib.rs index bb5b2790..000aceaa 100644 --- a/src/models/mod.rs +++ b/crates/codex-models/src/lib.rs @@ -1,10 +1,10 @@ //! Cross-layer data models. //! -//! Types in this module are shared between the api, db, services, tasks, and -//! utils layers without anyone needing to import "up the stack". Anything that +//! Types in this crate are shared between the api, db, services, tasks, and +//! utils crates without anyone needing to import "up the stack". Anything that //! both a repository and an API DTO need to reference belongs here so the -//! direction of the dependency stays one-way (consumers depend on `models`, -//! `models` depends on nothing else inside the crate beyond `utils`). +//! direction of the dependency stays one-way (consumers depend on +//! `codex-models`, this crate depends on nothing else inside Codex). pub mod filter; pub mod permissions; diff --git a/src/models/permissions.rs b/crates/codex-models/src/permissions.rs similarity index 100% rename from src/models/permissions.rs rename to crates/codex-models/src/permissions.rs diff --git a/src/models/plugin.rs b/crates/codex-models/src/plugin.rs similarity index 99% rename from src/models/plugin.rs rename to crates/codex-models/src/plugin.rs index 83e1efec..34cccff7 100644 --- a/src/models/plugin.rs +++ b/crates/codex-models/src/plugin.rs @@ -2,7 +2,7 @@ //! services layers. //! //! The JSON-RPC wire format and the search/match DTOs live next to the plugin -//! manager in [`crate::services::plugin::protocol`]. Only the types that both +//! manager in `codex::services::plugin::protocol`. Only the types that both //! a repository and a service need to speak (manifest descriptors, capability //! declarations, scope enums) live here so `db` can reference them without //! taking a hard dependency on `services`. diff --git a/src/models/preprocessing.rs b/crates/codex-models/src/preprocessing.rs similarity index 100% rename from src/models/preprocessing.rs rename to crates/codex-models/src/preprocessing.rs diff --git a/src/models/release.rs b/crates/codex-models/src/release.rs similarity index 94% rename from src/models/release.rs rename to crates/codex-models/src/release.rs index 02315569..e3ec133e 100644 --- a/src/models/release.rs +++ b/crates/codex-models/src/release.rs @@ -3,7 +3,7 @@ //! //! These are pure data shapes and small helpers. The ledger-shaped service //! logic (auto-ignore, candidate validation, language gating) stays in -//! [`crate::services::release`]; this module only holds the types and the +//! `codex::services::release`; this module only holds the types and the //! span helpers that repositories need to speak. use serde::{Deserialize, Serialize}; @@ -86,8 +86,8 @@ pub fn primary_value(spans: Option<&Vec<NumericSpan>>) -> Option<f64> { } /// Per-series ownership signature consumed by the auto-ignore logic in -/// [`crate::services::release::auto_ignore`]. Produced by -/// [`crate::db::repositories::SeriesRepository::get_owned_release_keys_for_series`]. +/// `codex::services::release::auto_ignore`. Produced by +/// `codex::db::repositories::SeriesRepository::get_owned_release_keys_for_series`. #[derive(Debug, Default, Clone)] pub struct OwnedReleaseKeys { /// `(volume, chapter)` pairs from book metadata, after filtering out diff --git a/src/models/sort.rs b/crates/codex-models/src/sort.rs similarity index 100% rename from src/models/sort.rs rename to crates/codex-models/src/sort.rs diff --git a/src/models/strategies.rs b/crates/codex-models/src/strategies.rs similarity index 100% rename from src/models/strategies.rs rename to crates/codex-models/src/strategies.rs diff --git a/src/models/task.rs b/crates/codex-models/src/task.rs similarity index 100% rename from src/models/task.rs rename to crates/codex-models/src/task.rs diff --git a/crates/codex-parsers/Cargo.toml b/crates/codex-parsers/Cargo.toml new file mode 100644 index 00000000..4ce59322 --- /dev/null +++ b/crates/codex-parsers/Cargo.toml @@ -0,0 +1,47 @@ +[package] +name = "codex-parsers" +version = "0.0.0" +edition = "2024" +publish = false + +[lib] +name = "codex_parsers" +path = "src/lib.rs" + +[features] +default = ["rar"] +rar = ["dep:unrar"] + +[dependencies] +anyhow = { workspace = true } +chrono = { workspace = true } +serde = { workspace = true } +tracing = { workspace = true } +codex-utils = { workspace = true } + +# URL decoding (EPUB OPF hrefs) +urlencoding = "2.1" + +# Archive formats +zip = "8.1" +unrar = { version = "0.5", optional = true } + +# PDF parsing +lopdf = "0.39" +pdfium-render = { version = "0.8", features = ["sync"] } + +# Image processing +image = { version = "0.25", features = ["avif"] } +resvg = "0.47" +jxl-oxide = "0.12" +infer = "0.19" + +# XML / metadata parsing +quick-xml = { version = "0.39", features = ["serialize"] } +serde_json = "1.0" + +# Regex (ISBN extraction) +regex = "1.10" + +[dev-dependencies] +tempfile = { workspace = true } diff --git a/src/parsers/cbr/mod.rs b/crates/codex-parsers/src/cbr/mod.rs similarity index 100% rename from src/parsers/cbr/mod.rs rename to crates/codex-parsers/src/cbr/mod.rs diff --git a/src/parsers/cbr/parser.rs b/crates/codex-parsers/src/cbr/parser.rs similarity index 97% rename from src/parsers/cbr/parser.rs rename to crates/codex-parsers/src/cbr/parser.rs index 55092733..5b4b564e 100644 --- a/src/parsers/cbr/parser.rs +++ b/crates/codex-parsers/src/cbr/parser.rs @@ -1,8 +1,8 @@ -use crate::parsers::image_utils::{create_page_info, is_image_file, process_image_data}; -use crate::parsers::traits::FormatParser; -use crate::parsers::{BookMetadata, FileFormat, parse_comic_info}; -use crate::utils::{CodexError, Result, hash_file}; +use crate::image_utils::{create_page_info, is_image_file, process_image_data}; +use crate::traits::FormatParser; +use crate::{BookMetadata, FileFormat, parse_comic_info}; use chrono::{DateTime, Utc}; +use codex_utils::{CodexError, Result, hash_file}; use std::path::Path; use unrar::Archive; @@ -209,7 +209,7 @@ pub fn extract_page_from_cbr_with_fallback<P: AsRef<Path>>( page_number: i32, fallback_on_invalid: bool, ) -> anyhow::Result<Vec<u8>> { - use crate::parsers::image_utils::is_valid_image_data; + use crate::image_utils::is_valid_image_data; let mut archive = unrar::Archive::new(path.as_ref()) .open_for_processing() diff --git a/src/parsers/cbz/mod.rs b/crates/codex-parsers/src/cbz/mod.rs similarity index 100% rename from src/parsers/cbz/mod.rs rename to crates/codex-parsers/src/cbz/mod.rs diff --git a/src/parsers/cbz/parser.rs b/crates/codex-parsers/src/cbz/parser.rs similarity index 96% rename from src/parsers/cbz/parser.rs rename to crates/codex-parsers/src/cbz/parser.rs index b112db12..6810953f 100644 --- a/src/parsers/cbz/parser.rs +++ b/crates/codex-parsers/src/cbz/parser.rs @@ -1,8 +1,8 @@ -use crate::parsers::image_utils::{create_page_info, is_image_file, process_image_data}; -use crate::parsers::traits::FormatParser; -use crate::parsers::{BookMetadata, FileFormat, parse_comic_info}; -use crate::utils::{Result, hash_file}; +use crate::image_utils::{create_page_info, is_image_file, process_image_data}; +use crate::traits::FormatParser; +use crate::{BookMetadata, FileFormat, parse_comic_info}; use chrono::{DateTime, Utc}; +use codex_utils::{Result, hash_file}; use std::fs::File; use std::io::Read; use std::path::Path; @@ -184,7 +184,7 @@ pub fn extract_page_from_cbz_with_fallback<P: AsRef<Path>>( page_number: i32, fallback_on_invalid: bool, ) -> anyhow::Result<Vec<u8>> { - use crate::parsers::image_utils::is_valid_image_data; + use crate::image_utils::is_valid_image_data; let file = File::open(path)?; let mut archive = ZipArchive::new(file)?; diff --git a/src/parsers/comic_info.rs b/crates/codex-parsers/src/comic_info.rs similarity index 99% rename from src/parsers/comic_info.rs rename to crates/codex-parsers/src/comic_info.rs index 834585d7..9a4ab294 100644 --- a/src/parsers/comic_info.rs +++ b/crates/codex-parsers/src/comic_info.rs @@ -1,4 +1,4 @@ -use crate::parsers::ComicInfo; +use crate::ComicInfo; use quick_xml::de::from_str; use serde::{Deserialize, Serialize}; diff --git a/src/parsers/epub/mod.rs b/crates/codex-parsers/src/epub/mod.rs similarity index 100% rename from src/parsers/epub/mod.rs rename to crates/codex-parsers/src/epub/mod.rs diff --git a/src/parsers/epub/parser.rs b/crates/codex-parsers/src/epub/parser.rs similarity index 98% rename from src/parsers/epub/parser.rs rename to crates/codex-parsers/src/epub/parser.rs index 83a1046d..c5b00035 100644 --- a/src/parsers/epub/parser.rs +++ b/crates/codex-parsers/src/epub/parser.rs @@ -1,11 +1,11 @@ -use crate::parsers::image_utils::{get_image_format, get_svg_dimensions, is_image_file}; -use crate::parsers::isbn_utils::extract_isbns; -use crate::parsers::metadata::{SpineItem, compute_epub_positions}; -use crate::parsers::opf; -use crate::parsers::traits::FormatParser; -use crate::parsers::{BookMetadata, FileFormat, ImageFormat, PageInfo}; -use crate::utils::{CodexError, Result, hash_file}; +use crate::image_utils::{get_image_format, get_svg_dimensions, is_image_file}; +use crate::isbn_utils::extract_isbns; +use crate::metadata::{SpineItem, compute_epub_positions}; +use crate::opf; +use crate::traits::FormatParser; +use crate::{BookMetadata, FileFormat, ImageFormat, PageInfo}; use chrono::{DateTime, Utc}; +use codex_utils::{CodexError, Result, hash_file}; use image::GenericImageView; use std::collections::HashMap; use std::fs::File; @@ -139,7 +139,7 @@ impl EpubParser { } /// Parse the EPUB container.xml to find the root file (usually content.opf) - pub(crate) fn find_root_file(archive: &mut ZipArchive<File>) -> Result<String> { + pub fn find_root_file(archive: &mut ZipArchive<File>) -> Result<String> { let mut container_file = archive .by_name("META-INF/container.xml") .map_err(|_| CodexError::ParseError("META-INF/container.xml not found".to_string()))?; @@ -211,7 +211,7 @@ impl EpubParser { /// /// Returns (manifest: id -> (href, media_type), spine_order: Vec<(href, media_type)>) #[allow(clippy::type_complexity)] - pub(crate) fn parse_opf( + pub fn parse_opf( archive: &mut ZipArchive<File>, opf_path: &str, ) -> Result<(HashMap<String, (String, String)>, Vec<(String, String)>)> { @@ -721,7 +721,7 @@ pub fn extract_cover_from_epub_with_fallback<P: AsRef<Path>>( path: P, fallback_on_invalid: bool, ) -> anyhow::Result<Vec<u8>> { - use crate::parsers::image_utils::is_valid_image_data; + use crate::image_utils::is_valid_image_data; let path = path.as_ref(); let file = File::open(path)?; @@ -860,7 +860,7 @@ pub fn extract_page_from_epub_with_fallback<P: AsRef<Path>>( } // For other pages, use alphabetical order - use crate::parsers::image_utils::is_valid_image_data; + use crate::image_utils::is_valid_image_data; let file = File::open(path)?; let mut archive = ZipArchive::new(file)?; diff --git a/src/parsers/image_utils.rs b/crates/codex-parsers/src/image_utils.rs similarity index 99% rename from src/parsers/image_utils.rs rename to crates/codex-parsers/src/image_utils.rs index d9d47cf4..fe329f91 100644 --- a/src/parsers/image_utils.rs +++ b/crates/codex-parsers/src/image_utils.rs @@ -1,4 +1,4 @@ -use crate::parsers::ImageFormat; +use crate::ImageFormat; use jxl_oxide::JxlImage; use resvg::usvg::{Options, Tree}; use std::io::Cursor; @@ -269,7 +269,7 @@ pub fn get_verified_image_format(filename: &str, data: &[u8]) -> Option<ImageFor } } -use crate::parsers::PageInfo; +use crate::PageInfo; /// Result of processing an image file from an archive #[derive(Debug)] diff --git a/src/parsers/isbn_utils.rs b/crates/codex-parsers/src/isbn_utils.rs similarity index 100% rename from src/parsers/isbn_utils.rs rename to crates/codex-parsers/src/isbn_utils.rs diff --git a/src/parsers/mod.rs b/crates/codex-parsers/src/lib.rs similarity index 54% rename from src/parsers/mod.rs rename to crates/codex-parsers/src/lib.rs index 73fb9a65..77b51891 100644 --- a/src/parsers/mod.rs +++ b/crates/codex-parsers/src/lib.rs @@ -1,3 +1,9 @@ +//! Codex file-format parsers (CBZ, CBR, EPUB, PDF) and shared metadata +//! utilities. +//! +//! Depends on `codex-utils` for the `CodexError` / `Result` types and the +//! file-level hasher. No upward deps to db/services/api. + #[cfg(feature = "rar")] pub mod cbr; pub mod cbz; diff --git a/src/parsers/metadata.rs b/crates/codex-parsers/src/metadata.rs similarity index 100% rename from src/parsers/metadata.rs rename to crates/codex-parsers/src/metadata.rs diff --git a/src/parsers/opf.rs b/crates/codex-parsers/src/opf.rs similarity index 99% rename from src/parsers/opf.rs rename to crates/codex-parsers/src/opf.rs index 2c6d6af0..e6dbf0ce 100644 --- a/src/parsers/opf.rs +++ b/crates/codex-parsers/src/opf.rs @@ -3,9 +3,9 @@ //! Parses Dublin Core metadata and Calibre extensions from OPF XML files. //! Used for both embedded EPUB OPF content and Calibre sidecar `metadata.opf` files. -use crate::parsers::ComicInfo; -use crate::parsers::isbn_utils::extract_isbns; -use crate::utils::{CodexError, Result}; +use crate::ComicInfo; +use crate::isbn_utils::extract_isbns; +use codex_utils::{CodexError, Result}; use serde::Serialize; use std::path::Path; diff --git a/src/parsers/pdf/mod.rs b/crates/codex-parsers/src/pdf/mod.rs similarity index 100% rename from src/parsers/pdf/mod.rs rename to crates/codex-parsers/src/pdf/mod.rs diff --git a/src/parsers/pdf/parser.rs b/crates/codex-parsers/src/pdf/parser.rs similarity index 99% rename from src/parsers/pdf/parser.rs rename to crates/codex-parsers/src/pdf/parser.rs index 693a3f3f..844030f4 100644 --- a/src/parsers/pdf/parser.rs +++ b/crates/codex-parsers/src/pdf/parser.rs @@ -1,9 +1,9 @@ -use crate::parsers::isbn_utils::extract_isbns; -use crate::parsers::pdf::renderer; -use crate::parsers::traits::FormatParser; -use crate::parsers::{BookMetadata, FileFormat, ImageFormat, PageInfo}; -use crate::utils::{CodexError, Result, hash_file}; +use crate::isbn_utils::extract_isbns; +use crate::pdf::renderer; +use crate::traits::FormatParser; +use crate::{BookMetadata, FileFormat, ImageFormat, PageInfo}; use chrono::{DateTime, Utc}; +use codex_utils::{CodexError, Result, hash_file}; use image::GenericImageView; use lopdf::{Document, Object, ObjectId}; use std::path::Path; diff --git a/src/parsers/pdf/renderer.rs b/crates/codex-parsers/src/pdf/renderer.rs similarity index 100% rename from src/parsers/pdf/renderer.rs rename to crates/codex-parsers/src/pdf/renderer.rs diff --git a/src/parsers/series_json.rs b/crates/codex-parsers/src/series_json.rs similarity index 99% rename from src/parsers/series_json.rs rename to crates/codex-parsers/src/series_json.rs index 965cbfa4..7d2a161a 100644 --- a/src/parsers/series_json.rs +++ b/crates/codex-parsers/src/series_json.rs @@ -3,7 +3,7 @@ //! Parses Mylar's `series.json` sidecar files (schema version 1.0.2) to extract //! series-level metadata such as publisher, year, description, and status. -use crate::utils::{CodexError, Result}; +use codex_utils::{CodexError, Result}; use serde::Deserialize; use std::path::Path; diff --git a/src/parsers/traits.rs b/crates/codex-parsers/src/traits.rs similarity index 88% rename from src/parsers/traits.rs rename to crates/codex-parsers/src/traits.rs index 678a5b1b..6b49b360 100644 --- a/src/parsers/traits.rs +++ b/crates/codex-parsers/src/traits.rs @@ -4,8 +4,8 @@ #![allow(dead_code)] -use crate::parsers::BookMetadata; -use crate::utils::Result; +use crate::BookMetadata; +use codex_utils::Result; use std::path::Path; /// Trait for parsing different file formats diff --git a/crates/codex-utils/Cargo.toml b/crates/codex-utils/Cargo.toml new file mode 100644 index 00000000..9dc122c3 --- /dev/null +++ b/crates/codex-utils/Cargo.toml @@ -0,0 +1,38 @@ +[package] +name = "codex-utils" +version = "0.0.0" +edition = "2024" +publish = false + +[lib] +name = "codex_utils" +path = "src/lib.rs" + +[dependencies] +anyhow = { workspace = true } +chrono = { workspace = true } +serde = { workspace = true } +tokio = { workspace = true } +uuid = { workspace = true } +codex-models = { workspace = true } + +# Crate-specific deps +aes-gcm = "0.10" +argon2 = "0.5" +base64 = "0.22" +chrono-tz = "0.10" +cron = "0.13" +image = { version = "0.25", features = ["avif"] } +jsonwebtoken = { version = "10", features = ["aws_lc_rs"] } +md-5 = "0.10" +quick-xml = { version = "0.39", features = ["serialize"] } +rand = "0.10" +serde_json = "1.0" +sha2 = "0.10" +thiserror = "2.0" +unicode-normalization = "0.1" +zip = "8.1" + +[dev-dependencies] +tempfile = { workspace = true } +serial_test = { workspace = true } diff --git a/src/utils/credential_encryption.rs b/crates/codex-utils/src/credential_encryption.rs similarity index 100% rename from src/utils/credential_encryption.rs rename to crates/codex-utils/src/credential_encryption.rs diff --git a/src/utils/cron.rs b/crates/codex-utils/src/cron.rs similarity index 100% rename from src/utils/cron.rs rename to crates/codex-utils/src/cron.rs diff --git a/src/utils/deadline.rs b/crates/codex-utils/src/deadline.rs similarity index 100% rename from src/utils/deadline.rs rename to crates/codex-utils/src/deadline.rs diff --git a/src/utils/error.rs b/crates/codex-utils/src/error.rs similarity index 100% rename from src/utils/error.rs rename to crates/codex-utils/src/error.rs diff --git a/src/utils/hasher.rs b/crates/codex-utils/src/hasher.rs similarity index 100% rename from src/utils/hasher.rs rename to crates/codex-utils/src/hasher.rs diff --git a/src/utils/json.rs b/crates/codex-utils/src/json.rs similarity index 100% rename from src/utils/json.rs rename to crates/codex-utils/src/json.rs diff --git a/src/utils/jwt.rs b/crates/codex-utils/src/jwt.rs similarity index 99% rename from src/utils/jwt.rs rename to crates/codex-utils/src/jwt.rs index 335f9b39..24450f25 100644 --- a/src/utils/jwt.rs +++ b/crates/codex-utils/src/jwt.rs @@ -4,9 +4,9 @@ #![allow(dead_code)] -use crate::models::permissions::UserRole; use anyhow::{Context, Result}; use chrono::{Duration, Utc}; +use codex_models::permissions::UserRole; use jsonwebtoken::{DecodingKey, EncodingKey, Header, Validation, decode, encode}; use serde::{Deserialize, Serialize}; use uuid::Uuid; diff --git a/src/utils/mod.rs b/crates/codex-utils/src/lib.rs similarity index 71% rename from src/utils/mod.rs rename to crates/codex-utils/src/lib.rs index bc881013..f0e793be 100644 --- a/src/utils/mod.rs +++ b/crates/codex-utils/src/lib.rs @@ -1,3 +1,9 @@ +//! Codex utility helpers shared across the workspace. +//! +//! Pure helpers (hashing, password, cron parsing, jwt, error type, custom +//! serde adapters, natural sort, unicode normalization). Depends only on +//! `codex-models` for the `UserRole` type used by `jwt`. + pub mod credential_encryption; pub mod cron; pub mod deadline; diff --git a/src/utils/natural_sort.rs b/crates/codex-utils/src/natural_sort.rs similarity index 100% rename from src/utils/natural_sort.rs rename to crates/codex-utils/src/natural_sort.rs diff --git a/src/utils/password.rs b/crates/codex-utils/src/password.rs similarity index 100% rename from src/utils/password.rs rename to crates/codex-utils/src/password.rs diff --git a/src/utils/search.rs b/crates/codex-utils/src/search.rs similarity index 100% rename from src/utils/search.rs rename to crates/codex-utils/src/search.rs diff --git a/src/utils/serde.rs b/crates/codex-utils/src/serde.rs similarity index 100% rename from src/utils/serde.rs rename to crates/codex-utils/src/serde.rs diff --git a/src/api/docs.rs b/src/api/docs.rs index cfc7ec2b..dd8ca5c7 100644 --- a/src/api/docs.rs +++ b/src/api/docs.rs @@ -638,14 +638,14 @@ The following paths are exempt from rate limiting: v1::dto::DetectedSeriesMetadataDto, // Strategy types - crate::models::SeriesStrategy, - crate::models::BookStrategy, - crate::models::FlatStrategyConfig, - crate::models::PublisherHierarchyConfig, - crate::models::CalibreStrategyConfig, - crate::models::CalibreSeriesMode, - crate::models::CustomStrategyConfig, - crate::models::SmartBookConfig, + codex_models::SeriesStrategy, + codex_models::BookStrategy, + codex_models::FlatStrategyConfig, + codex_models::PublisherHierarchyConfig, + codex_models::CalibreStrategyConfig, + codex_models::CalibreSeriesMode, + codex_models::CustomStrategyConfig, + codex_models::SmartBookConfig, v1::dto::SeriesDto, v1::dto::SeriesListResponse, v1::dto::SearchSeriesRequest, diff --git a/src/api/extractors/auth.rs b/src/api/extractors/auth.rs index a84dacae..78a13226 100644 --- a/src/api/extractors/auth.rs +++ b/src/api/extractors/auth.rs @@ -3,10 +3,10 @@ use tracing::debug; use crate::api::error::ApiError; use crate::api::permissions::{Permission, UserRole}; use crate::db::repositories::{ApiKeyRepository, UserRepository}; -use crate::utils::{jwt::JwtService, password}; use axum::http::header::COOKIE; use axum::{extract::FromRequestParts, http::request::Parts}; use chrono::{DateTime, Utc}; +use codex_utils::{jwt::JwtService, password}; use dashmap::DashMap; use sea_orm::DatabaseConnection; use std::collections::HashSet; diff --git a/src/api/permissions.rs b/src/api/permissions.rs index 03d72562..5226872e 100644 --- a/src/api/permissions.rs +++ b/src/api/permissions.rs @@ -1,8 +1,8 @@ //! Re-export of the cross-layer permission types. //! -//! The canonical definitions live in [`crate::models::permissions`] so that +//! The canonical definitions live in [`codex_models::permissions`] so that //! the db and utils layers can reference `UserRole` without depending on the //! api layer. This module preserves the historic `codex::api::permissions::*` //! path used by integration tests and downstream code. -pub use crate::models::permissions::*; +pub use codex_models::permissions::*; diff --git a/src/api/routes/komga/dto/mod.rs b/src/api/routes/komga/dto/mod.rs index 49f6cb05..0b9b4ff4 100644 --- a/src/api/routes/komga/dto/mod.rs +++ b/src/api/routes/komga/dto/mod.rs @@ -13,7 +13,7 @@ pub mod stubs; pub mod user; // Re-export serde helpers from crate-level utils for convenience -pub use crate::utils::default_true; +pub use codex_utils::default_true; // Re-export commonly used types for the public Komga-compatible API. // These may not all be used internally but are part of the API contract. diff --git a/src/api/routes/komga/handlers/libraries.rs b/src/api/routes/komga/handlers/libraries.rs index e40a5f4d..f39c8182 100644 --- a/src/api/routes/komga/handlers/libraries.rs +++ b/src/api/routes/komga/handlers/libraries.rs @@ -271,11 +271,11 @@ pub async fn extract_page_image( // Use the appropriate parser based on format let image_data = match file_format.to_uppercase().as_str() { - "CBZ" => crate::parsers::cbz::extract_page_from_cbz(path, page_number)?, + "CBZ" => codex_parsers::cbz::extract_page_from_cbz(path, page_number)?, #[cfg(feature = "rar")] - "CBR" => crate::parsers::cbr::extract_page_from_cbr(path, page_number)?, - "EPUB" => crate::parsers::epub::extract_page_from_epub(path, page_number)?, - "PDF" => crate::parsers::pdf::extract_page_from_pdf(path, page_number)?, + "CBR" => codex_parsers::cbr::extract_page_from_cbr(path, page_number)?, + "EPUB" => codex_parsers::epub::extract_page_from_epub(path, page_number)?, + "PDF" => codex_parsers::pdf::extract_page_from_pdf(path, page_number)?, _ => { return Err(anyhow::anyhow!( "Unsupported format for page extraction: {}", diff --git a/src/api/routes/komga/handlers/manifest.rs b/src/api/routes/komga/handlers/manifest.rs index ed65863a..2607f65b 100644 --- a/src/api/routes/komga/handlers/manifest.rs +++ b/src/api/routes/komga/handlers/manifest.rs @@ -10,7 +10,6 @@ use crate::api::{ permissions::Permission, }; use crate::db::repositories::{BookMetadataRepository, BookRepository, SeriesRepository}; -use crate::parsers::epub::EpubParser; use crate::require_permission; use axum::{ body::Body, @@ -18,6 +17,7 @@ use axum::{ http::{StatusCode, header}, response::Response, }; +use codex_parsers::epub::EpubParser; use std::collections::HashSet; use std::io::Read; use std::sync::Arc; diff --git a/src/api/routes/komga/handlers/read_progress.rs b/src/api/routes/komga/handlers/read_progress.rs index 8b476840..fc5e98d3 100644 --- a/src/api/routes/komga/handlers/read_progress.rs +++ b/src/api/routes/komga/handlers/read_progress.rs @@ -320,9 +320,9 @@ pub async fn put_progression( // Normalize totalProgression using server-side positions if available let (total_progression, current_page) = if let Some(ref positions_json) = book.epub_positions { if let Ok(positions) = - serde_json::from_str::<Vec<crate::parsers::EpubPosition>>(positions_json) + serde_json::from_str::<Vec<codex_parsers::EpubPosition>>(positions_json) { - if let Some((normalized, position)) = crate::parsers::normalize_progression( + if let Some((normalized, position)) = codex_parsers::normalize_progression( &positions, client_href, client_total_progression, diff --git a/src/api/routes/koreader/handlers/sync.rs b/src/api/routes/koreader/handlers/sync.rs index eee54e50..6e1c1753 100644 --- a/src/api/routes/koreader/handlers/sync.rs +++ b/src/api/routes/koreader/handlers/sync.rs @@ -10,9 +10,9 @@ use crate::api::permissions::Permission; use crate::api::routes::koreader::dto::progress::DocumentProgressDto; use crate::db::entities::books; use crate::db::repositories::{BookRepository, ReadProgressRepository}; -use crate::parsers::EpubPosition; use axum::Json; use axum::extract::{Path, State}; +use codex_parsers::EpubPosition; use std::sync::Arc; /// GET /koreader/syncs/progress/{document} diff --git a/src/api/routes/v1/dto/book.rs b/src/api/routes/v1/dto/book.rs index 765838c1..a45fe4cd 100644 --- a/src/api/routes/v1/dto/book.rs +++ b/src/api/routes/v1/dto/book.rs @@ -408,9 +408,9 @@ pub struct BookCoverListResponse { pub covers: Vec<BookCoverDto>, } -// Sort parameters live in `crate::models::sort` so db repositories can take +// Sort parameters live in `codex_models::sort` so db repositories can take // typed sort params without depending on the api layer. -pub use crate::models::sort::{BookSortField, BookSortParam}; +pub use codex_models::sort::{BookSortField, BookSortParam}; /// Book data transfer object #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] diff --git a/src/api/routes/v1/dto/common.rs b/src/api/routes/v1/dto/common.rs index 84b4463c..178a236d 100644 --- a/src/api/routes/v1/dto/common.rs +++ b/src/api/routes/v1/dto/common.rs @@ -3,7 +3,7 @@ use utoipa::{IntoParams, ToSchema}; // Re-export serde helpers from crate-level utils for convenience #[allow(unused_imports)] // default_true available for DTOs that need it -pub use crate::utils::{default_true, deserialize_optional_nullable, is_false}; +pub use codex_utils::{default_true, deserialize_optional_nullable, is_false}; // ============================================================================= // Pagination Constants diff --git a/src/api/routes/v1/dto/filter.rs b/src/api/routes/v1/dto/filter.rs index 600de134..c87e4424 100644 --- a/src/api/routes/v1/dto/filter.rs +++ b/src/api/routes/v1/dto/filter.rs @@ -1,6 +1,6 @@ //! Filter DTOs. //! -//! The operator and condition enums live in [`crate::models::filter`] so +//! The operator and condition enums live in [`codex_models::filter`] so //! services and repositories can speak the same vocabulary without depending //! on the api layer. The request envelopes that wrap them remain here as API //! contract types. @@ -8,7 +8,7 @@ use serde::{Deserialize, Serialize}; use utoipa::ToSchema; -pub use crate::models::filter::{ +pub use codex_models::filter::{ BookCondition, BoolOperator, DateOperator, FieldOperator, NumberOperator, SeriesCondition, UuidOperator, }; diff --git a/src/api/routes/v1/dto/library.rs b/src/api/routes/v1/dto/library.rs index 8b099882..d05c68fb 100644 --- a/src/api/routes/v1/dto/library.rs +++ b/src/api/routes/v1/dto/library.rs @@ -5,7 +5,7 @@ use utoipa::ToSchema; use super::ScanningConfigDto; use super::common::is_false; use super::patch::PatchValue; -use crate::models::{BookStrategy, NumberStrategy, SeriesStrategy}; +use codex_models::{BookStrategy, NumberStrategy, SeriesStrategy}; /// Library data transfer object #[derive(Debug, Serialize, Deserialize, ToSchema)] diff --git a/src/api/routes/v1/dto/scan.rs b/src/api/routes/v1/dto/scan.rs index f7f8d776..eb17dc3f 100644 --- a/src/api/routes/v1/dto/scan.rs +++ b/src/api/routes/v1/dto/scan.rs @@ -121,10 +121,10 @@ impl ScanningConfigDto { /// If `cron_timezone` is set, validates it as a valid IANA timezone name. pub fn validated(self) -> Result<Self, String> { if let Some(cron) = &self.cron_schedule { - crate::utils::cron::validate_cron_expression(cron).map_err(|e| e.to_string())?; + codex_utils::cron::validate_cron_expression(cron).map_err(|e| e.to_string())?; } if let Some(tz) = &self.cron_timezone { - crate::utils::cron::validate_timezone(tz).map_err(|e| e.to_string())?; + codex_utils::cron::validate_timezone(tz).map_err(|e| e.to_string())?; } Ok(self) } diff --git a/src/api/routes/v1/dto/series.rs b/src/api/routes/v1/dto/series.rs index 1f08c4b9..e7808f65 100644 --- a/src/api/routes/v1/dto/series.rs +++ b/src/api/routes/v1/dto/series.rs @@ -4,9 +4,9 @@ use utoipa::ToSchema; use super::common::PaginatedResponse; -// Sort parameters live in `crate::models::sort` so db repositories can take +// Sort parameters live in `codex_models::sort` so db repositories can take // typed sort params without depending on the api layer. -pub use crate::models::sort::{SeriesSortField, SeriesSortParam, SortDirection}; +pub use codex_models::sort::{SeriesSortField, SeriesSortParam, SortDirection}; /// Series data transfer object #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] diff --git a/src/api/routes/v1/handlers/api_keys.rs b/src/api/routes/v1/handlers/api_keys.rs index 5229335a..736c9091 100644 --- a/src/api/routes/v1/handlers/api_keys.rs +++ b/src/api/routes/v1/handlers/api_keys.rs @@ -12,7 +12,6 @@ use crate::api::{ }; use crate::db::entities::api_keys; use crate::db::repositories::ApiKeyRepository; -use crate::utils::password; use axum::{ Json, extract::{Path, Query, State}, @@ -20,6 +19,7 @@ use axum::{ response::Response, }; use chrono::Utc; +use codex_utils::password; use rand::RngExt; use sea_orm::ActiveModelTrait; use std::collections::HashSet; diff --git a/src/api/routes/v1/handlers/auth.rs b/src/api/routes/v1/handlers/auth.rs index e55ed857..d4255343 100644 --- a/src/api/routes/v1/handlers/auth.rs +++ b/src/api/routes/v1/handlers/auth.rs @@ -13,7 +13,6 @@ use crate::db::{ repositories::{EmailVerificationTokenRepository, SettingsRepository, UserRepository}, }; use crate::services::RefreshTokenError; -use crate::utils::password; use axum::{ Json, extract::State, @@ -21,6 +20,7 @@ use axum::{ response::{IntoResponse, Response}, }; use chrono::Utc; +use codex_utils::password; use sea_orm::ActiveModelTrait; use sea_orm::Set; use std::env; diff --git a/src/api/routes/v1/handlers/books.rs b/src/api/routes/v1/handlers/books.rs index 784ddc6f..471c7744 100644 --- a/src/api/routes/v1/handlers/books.rs +++ b/src/api/routes/v1/handlers/books.rs @@ -24,10 +24,6 @@ use crate::db::repositories::{ }; use crate::require_permission; use crate::services::FilterService; -use crate::utils::{ - json_merge_patch, normalize_for_search, parse_custom_metadata, serialize_custom_metadata, - validate_custom_metadata_size, -}; use axum::{ Json, body::Body, @@ -35,6 +31,10 @@ use axum::{ http::{StatusCode, header}, response::{IntoResponse, Response}, }; +use codex_utils::{ + json_merge_patch, normalize_for_search, parse_custom_metadata, serialize_custom_metadata, + validate_custom_metadata_size, +}; use serde::Deserialize; use std::collections::{HashMap, HashSet}; use std::sync::Arc; @@ -3473,7 +3473,7 @@ pub async fn upload_book_cover( .map_err(|e| ApiError::BadRequest(format!("Invalid image file: {}", e)))?; // Compute hash of image data for deduplication - let image_hash = crate::utils::hasher::hash_bytes(&image_data); + let image_hash = codex_utils::hasher::hash_bytes(&image_data); let short_hash = &image_hash[..16]; // Create covers directory within uploads dir if it doesn't exist diff --git a/src/api/routes/v1/handlers/bulk_metadata.rs b/src/api/routes/v1/handlers/bulk_metadata.rs index 541aa778..3e445ae5 100644 --- a/src/api/routes/v1/handlers/bulk_metadata.rs +++ b/src/api/routes/v1/handlers/bulk_metadata.rs @@ -11,13 +11,13 @@ use crate::db::repositories::{ SeriesRepository, TagRepository, }; use crate::require_permission; -use crate::utils::{ - json_merge_patch, parse_custom_metadata, serialize_custom_metadata, - validate_custom_metadata_size, -}; use axum::{Json, extract::State}; use chrono::Utc; use codex_events::{EntityChangeEvent, EntityEvent}; +use codex_utils::{ + json_merge_patch, parse_custom_metadata, serialize_custom_metadata, + validate_custom_metadata_size, +}; use sea_orm::{ActiveModelTrait, Set}; use std::sync::Arc; use uuid::Uuid; diff --git a/src/api/routes/v1/handlers/libraries.rs b/src/api/routes/v1/handlers/libraries.rs index 2fff635d..994b1e97 100644 --- a/src/api/routes/v1/handlers/libraries.rs +++ b/src/api/routes/v1/handlers/libraries.rs @@ -13,7 +13,6 @@ use crate::api::{ }; use crate::db::entities::libraries; use crate::db::repositories::{CreateLibraryParams, LibraryRepository}; -use crate::models::{BookStrategy, NumberStrategy, SeriesStrategy}; use crate::require_permission; use crate::scanner::strategies::create_strategy; use axum::{ @@ -22,6 +21,7 @@ use axum::{ response::Response, }; use chrono::Utc; +use codex_models::{BookStrategy, NumberStrategy, SeriesStrategy}; use sea_orm::DatabaseConnection; use std::sync::Arc; use uuid::Uuid; diff --git a/src/api/routes/v1/handlers/pages.rs b/src/api/routes/v1/handlers/pages.rs index b0251090..eedb6e3c 100644 --- a/src/api/routes/v1/handlers/pages.rs +++ b/src/api/routes/v1/handlers/pages.rs @@ -5,13 +5,13 @@ use crate::api::{ }; use crate::db::repositories::{BookCoversRepository, BookRepository, PageRepository}; use crate::require_permission; -use crate::utils::{DeadlineResult, with_deadline}; use axum::{ body::Body, extract::{Path, State}, http::{HeaderMap, StatusCode, header}, response::Response, }; +use codex_utils::{DeadlineResult, with_deadline}; use httpdate::fmt_http_date; use image::{ImageFormat, imageops::FilterType}; use std::io::Cursor; @@ -253,22 +253,22 @@ async fn serve_pdf_page_with_streaming( // same book skip the per-page PDFium open. If PDFium isn't initialised // (no library binding available), fall back to the legacy path which can // serve embedded JPEGs directly via lopdf. - let render_result = if crate::parsers::pdf::static_pdfium().is_some() { + let render_result = if codex_parsers::pdf::static_pdfium().is_some() { let cache = state.pdf_handle_cache.clone(); let opener_path = path.clone(); let lookup_path = path.clone(); tokio::task::spawn_blocking(move || -> anyhow::Result<Vec<u8>> { let doc_arc = cache.get_or_open(book_id, lookup_path, move || { - crate::parsers::pdf::open_pdf_document(&opener_path) + codex_parsers::pdf::open_pdf_document(&opener_path) })?; let doc = doc_arc.blocking_lock(); - crate::parsers::pdf::render_page_from_doc(&doc, page_number, dpi) + codex_parsers::pdf::render_page_from_doc(&doc, page_number, dpi) }) .await .map_err(|e| ApiError::Internal(format!("Task join error: {}", e)))? } else { tokio::task::spawn_blocking(move || { - crate::parsers::pdf::extract_page_from_pdf_with_dpi(&path, page_number, dpi) + codex_parsers::pdf::extract_page_from_pdf_with_dpi(&path, page_number, dpi) }) .await .map_err(|e| ApiError::Internal(format!("Task join error: {}", e)))? @@ -560,7 +560,7 @@ async fn generate_book_thumbnail( // Render in blocking task to avoid blocking async runtime let path = std::path::PathBuf::from(&book.path); let data = tokio::task::spawn_blocking(move || { - crate::parsers::pdf::extract_page_from_pdf_with_dpi(&path, 1, dpi) + codex_parsers::pdf::extract_page_from_pdf_with_dpi(&path, 1, dpi) }) .await .map_err(|e| ApiError::Internal(format!("Task join error: {}", e)))? @@ -707,11 +707,11 @@ async fn extract_page_image( // Use spawn_blocking for CPU-intensive file parsing operations tokio::task::spawn_blocking(move || match format.as_str() { - "CBZ" => crate::parsers::cbz::extract_page_from_cbz(&path, page_number), + "CBZ" => codex_parsers::cbz::extract_page_from_cbz(&path, page_number), #[cfg(feature = "rar")] - "CBR" => crate::parsers::cbr::extract_page_from_cbr(&path, page_number), - "EPUB" => crate::parsers::epub::extract_page_from_epub(&path, page_number), - "PDF" => crate::parsers::pdf::extract_page_from_pdf(&path, page_number), + "CBR" => codex_parsers::cbr::extract_page_from_cbr(&path, page_number), + "EPUB" => codex_parsers::epub::extract_page_from_epub(&path, page_number), + "PDF" => codex_parsers::pdf::extract_page_from_pdf(&path, page_number), _ => anyhow::bail!("Unsupported format: {}", format), }) .await diff --git a/src/api/routes/v1/handlers/read_progress.rs b/src/api/routes/v1/handlers/read_progress.rs index 1ba179e6..119785be 100644 --- a/src/api/routes/v1/handlers/read_progress.rs +++ b/src/api/routes/v1/handlers/read_progress.rs @@ -375,9 +375,9 @@ pub async fn put_progression( let canonical_progression = if has_cfi { if let Some(ref spine_json) = book.epub_spine_items { if let Ok(spine_items) = - serde_json::from_str::<Vec<crate::parsers::SpineItem>>(spine_json) + serde_json::from_str::<Vec<codex_parsers::SpineItem>>(spine_json) { - crate::parsers::char_to_byte_progression(&spine_items, client_total_progression) + codex_parsers::char_to_byte_progression(&spine_items, client_total_progression) } else { client_total_progression } @@ -391,13 +391,11 @@ pub async fn put_progression( // Normalize totalProgression using server-side positions if available let (total_progression, current_page) = if let Some(ref positions_json) = book.epub_positions { if let Ok(positions) = - serde_json::from_str::<Vec<crate::parsers::EpubPosition>>(positions_json) + serde_json::from_str::<Vec<codex_parsers::EpubPosition>>(positions_json) { - if let Some((normalized, position)) = crate::parsers::normalize_progression( - &positions, - client_href, - canonical_progression, - ) { + if let Some((normalized, position)) = + codex_parsers::normalize_progression(&positions, client_href, canonical_progression) + { (normalized, position) } else { let page = if book.page_count > 0 { diff --git a/src/api/routes/v1/handlers/series.rs b/src/api/routes/v1/handlers/series.rs index a900b5ef..c625312b 100644 --- a/src/api/routes/v1/handlers/series.rs +++ b/src/api/routes/v1/handlers/series.rs @@ -37,10 +37,6 @@ use crate::require_permission; use crate::services::release::upstream_gap::{ UpstreamGap, UpstreamGapInputs, compute_upstream_gap, }; -use crate::utils::{ - json_merge_patch, normalize_for_search, parse_custom_metadata, serialize_custom_metadata, - validate_custom_metadata_size, -}; use axum::{ Json, body::Body, @@ -50,6 +46,10 @@ use axum::{ }; use chrono::Utc; use codex_events::{EntityChangeEvent, EntityEvent, EntityType}; +use codex_utils::{ + json_merge_patch, normalize_for_search, parse_custom_metadata, serialize_custom_metadata, + validate_custom_metadata_size, +}; use httpdate::fmt_http_date; use sea_orm::DatabaseConnection; use serde::Deserialize; @@ -1669,7 +1669,7 @@ pub async fn upload_series_cover( .map_err(|e| ApiError::BadRequest(format!("Invalid image file: {}", e)))?; // Compute hash of image data for deduplication - let image_hash = crate::utils::hasher::hash_bytes(&image_data); + let image_hash = codex_utils::hasher::hash_bytes(&image_data); // Use first 16 chars of hash for filename (64 chars is excessive) let short_hash = &image_hash[..16]; diff --git a/src/api/routes/v1/handlers/setup.rs b/src/api/routes/v1/handlers/setup.rs index ccc02ce6..d16da3c8 100644 --- a/src/api/routes/v1/handlers/setup.rs +++ b/src/api/routes/v1/handlers/setup.rs @@ -13,7 +13,6 @@ use crate::db::{ repositories::{SettingsRepository, UserRepository}, }; use crate::require_permission; -use crate::utils::password; use axum::{ Json, extract::State, @@ -21,6 +20,7 @@ use axum::{ response::{IntoResponse, Response}, }; use chrono::Utc; +use codex_utils::password; use std::sync::Arc; use uuid::Uuid; diff --git a/src/api/routes/v1/handlers/users.rs b/src/api/routes/v1/handlers/users.rs index 1ffb1fbc..6ab6fd8d 100644 --- a/src/api/routes/v1/handlers/users.rs +++ b/src/api/routes/v1/handlers/users.rs @@ -11,13 +11,13 @@ use crate::api::{ use crate::db::entities::users; use crate::db::repositories::{SharingTagRepository, UserListFilter, UserRepository}; use crate::require_permission; -use crate::utils::password; use axum::{ Json, extract::{Path, Query, State}, response::Response, }; use chrono::Utc; +use codex_utils::password; use std::sync::Arc; use uuid::Uuid; diff --git a/src/commands/scan.rs b/src/commands/scan.rs index 338e8aa9..0d6d2678 100644 --- a/src/commands/scan.rs +++ b/src/commands/scan.rs @@ -1,5 +1,5 @@ -use crate::parsers::BookMetadata; use crate::scanner::{analyze_file, detect_format}; +use codex_parsers::BookMetadata; use std::path::PathBuf; use tabled::{Table, Tabled}; diff --git a/src/commands/seed.rs b/src/commands/seed.rs index b877af73..326d83e1 100644 --- a/src/commands/seed.rs +++ b/src/commands/seed.rs @@ -7,12 +7,12 @@ use crate::db::repositories::{ api_key::ApiKeyRepository, library::CreateLibraryParams, library::LibraryRepository, plugins::PluginsRepository, user::UserRepository, }; -use crate::models::{BookStrategy, NumberStrategy, SeriesStrategy}; use crate::services::plugin::protocol::PluginScope; -use crate::utils::password::hash_password; use anyhow::{Context, Result}; use chrono::Utc; use codex_config::{Config, EnvOverride}; +use codex_models::{BookStrategy, NumberStrategy, SeriesStrategy}; +use codex_utils::password::hash_password; use rand::RngExt; use serde::Deserialize; use std::collections::HashMap; diff --git a/src/commands/serve.rs b/src/commands/serve.rs index c187f500..479db1eb 100644 --- a/src/commands/serve.rs +++ b/src/commands/serve.rs @@ -178,7 +178,7 @@ pub async fn serve_command(config_path: PathBuf) -> anyhow::Result<()> { .as_ref() .filter(|s| !s.is_empty()) .map(std::path::Path::new); - match crate::parsers::pdf::init_pdfium(pdfium_path) { + match codex_parsers::pdf::init_pdfium(pdfium_path) { Ok(()) => { info!("PDFium library initialized successfully"); } @@ -461,7 +461,7 @@ pub async fn serve_command(config_path: PathBuf) -> anyhow::Result<()> { )); let api_state = Arc::new(crate::api::AppState { db: db.sea_orm_connection().clone(), - jwt_service: Arc::new(crate::utils::jwt::JwtService::new( + jwt_service: Arc::new(codex_utils::jwt::JwtService::new( config.auth.jwt_secret.clone(), config.auth.jwt_expiry_hours, )), diff --git a/src/commands/worker.rs b/src/commands/worker.rs index e6df5dea..5decba22 100644 --- a/src/commands/worker.rs +++ b/src/commands/worker.rs @@ -111,7 +111,7 @@ pub async fn worker_command(config_path: PathBuf) -> anyhow::Result<()> { .as_ref() .filter(|s| !s.is_empty()) .map(std::path::Path::new); - match crate::parsers::pdf::init_pdfium(pdfium_path) { + match codex_parsers::pdf::init_pdfium(pdfium_path) { Ok(()) => { info!("PDFium library initialized successfully"); } diff --git a/src/db/entities/plugins.rs b/src/db/entities/plugins.rs index cc2116df..5420098e 100644 --- a/src/db/entities/plugins.rs +++ b/src/db/entities/plugins.rs @@ -756,8 +756,8 @@ impl Model { } /// Parse the scopes JSON array into a Vec<PluginScope> - pub fn scopes_vec(&self) -> Vec<crate::models::plugin::PluginScope> { - use crate::models::plugin::PluginScope; + pub fn scopes_vec(&self) -> Vec<codex_models::plugin::PluginScope> { + use codex_models::plugin::PluginScope; self.scopes .as_array() @@ -770,7 +770,7 @@ impl Model { } /// Check if the plugin supports a specific scope - pub fn has_scope(&self, scope: &crate::models::plugin::PluginScope) -> bool { + pub fn has_scope(&self, scope: &codex_models::plugin::PluginScope) -> bool { self.scopes_vec().contains(scope) } @@ -838,7 +838,7 @@ impl Model { } /// Get the cached manifest if available - pub fn cached_manifest(&self) -> Option<crate::models::plugin::PluginManifest> { + pub fn cached_manifest(&self) -> Option<codex_models::plugin::PluginManifest> { self.manifest .as_ref() .and_then(|m| serde_json::from_value(m.clone()).ok()) diff --git a/src/db/entities/users.rs b/src/db/entities/users.rs index 1be76861..ec2b47e7 100644 --- a/src/db/entities/users.rs +++ b/src/db/entities/users.rs @@ -3,7 +3,7 @@ use sea_orm::entity::prelude::*; use serde::{Deserialize, Serialize}; use uuid::Uuid; -use crate::models::permissions::UserRole; +use codex_models::permissions::UserRole; #[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)] #[sea_orm(table_name = "users")] diff --git a/src/db/mod.rs b/src/db/mod.rs index 55b228e9..eaff8ea1 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -10,6 +10,6 @@ pub use connection::Database; // Re-export SeaORM entities for use throughout the application // Re-export scanning strategies for convenience -pub use crate::models::ScanningStrategy; +pub use codex_models::ScanningStrategy; // Re-export CreateLibraryParams for convenience diff --git a/src/db/repositories/book.rs b/src/db/repositories/book.rs index 1ce2811b..6ee8a351 100644 --- a/src/db/repositories/book.rs +++ b/src/db/repositories/book.rs @@ -17,8 +17,8 @@ use uuid::Uuid; use crate::db::entities::{books, prelude::*}; use crate::db::repositories::SeriesRepository; use crate::observability::repo::db_system_str; -use crate::utils::normalize_for_search; use codex_events::{EntityChangeEvent, EntityEvent, EventBroadcaster}; +use codex_utils::normalize_for_search; /// Options for querying books with filtering, sorting, and pagination #[derive(Debug, Clone, Default)] @@ -912,14 +912,14 @@ impl BookRepository { pub async fn list_by_ids_sorted( db: &DatabaseConnection, ids: &[Uuid], - sort: &crate::models::sort::BookSortParam, + sort: &codex_models::sort::BookSortParam, user_id: Option<Uuid>, include_deleted: bool, offset: u64, limit: u64, ) -> Result<(Vec<books::Model>, u64)> { use crate::db::entities::{book_metadata, read_progress, series, series_metadata}; - use crate::models::sort::{BookSortField, SortDirection}; + use codex_models::sort::{BookSortField, SortDirection}; use sea_orm::{Condition, JoinType}; if ids.is_empty() { @@ -1193,13 +1193,13 @@ impl BookRepository { pub async fn list_by_library_sorted( db: &DatabaseConnection, library_id: Uuid, - sort: &crate::models::sort::BookSortParam, + sort: &codex_models::sort::BookSortParam, include_deleted: bool, page: u64, page_size: u64, ) -> Result<(Vec<books::Model>, u64)> { use crate::db::entities::{book_metadata, series, series_metadata}; - use crate::models::sort::{BookSortField, SortDirection}; + use codex_models::sort::{BookSortField, SortDirection}; use sea_orm::JoinType; // Build base query @@ -3379,7 +3379,7 @@ mod tests { use crate::db::entities::users; use crate::db::repositories::{ReadProgressRepository, UserRepository}; - use crate::utils::password; + use codex_utils::password; let password_hash = password::hash_password("test123").unwrap(); let user = users::Model { diff --git a/src/db/repositories/library.rs b/src/db/repositories/library.rs index f4557502..1e356b05 100644 --- a/src/db/repositories/library.rs +++ b/src/db/repositories/library.rs @@ -12,8 +12,8 @@ use sea_orm::{ use uuid::Uuid; use crate::db::entities::{libraries, prelude::*}; -use crate::models::{BookStrategy, NumberStrategy, SeriesStrategy}; use crate::observability::repo::db_system_str; +use codex_models::{BookStrategy, NumberStrategy, SeriesStrategy}; /// Parameters for creating a new library #[derive(Debug, Clone)] @@ -348,8 +348,8 @@ impl LibraryRepository { /// Returns an empty vector if no rules are configured or if parsing fails. pub fn get_preprocessing_rules( library: &libraries::Model, - ) -> Vec<crate::models::preprocessing::PreprocessingRule> { - use crate::models::preprocessing::parse_preprocessing_rules; + ) -> Vec<codex_models::preprocessing::PreprocessingRule> { + use codex_models::preprocessing::parse_preprocessing_rules; match parse_preprocessing_rules(library.title_preprocessing_rules.as_deref()) { Ok(rules) => rules, @@ -370,8 +370,8 @@ impl LibraryRepository { /// Returns None if no conditions are configured or if parsing fails. pub fn get_auto_match_conditions( library: &libraries::Model, - ) -> Option<crate::models::preprocessing::AutoMatchConditions> { - use crate::models::preprocessing::parse_auto_match_conditions; + ) -> Option<codex_models::preprocessing::AutoMatchConditions> { + use codex_models::preprocessing::parse_auto_match_conditions; match parse_auto_match_conditions(library.auto_match_conditions.as_deref()) { Ok(conditions) => conditions, @@ -966,7 +966,7 @@ mod tests { #[tokio::test] async fn test_get_auto_match_conditions_valid() { - use crate::models::preprocessing::{ConditionMode, ConditionOperator}; + use codex_models::preprocessing::{ConditionMode, ConditionOperator}; let (db, _temp_dir) = create_test_db().await; diff --git a/src/db/repositories/metadata.rs b/src/db/repositories/metadata.rs index 61789849..79f70b3a 100644 --- a/src/db/repositories/metadata.rs +++ b/src/db/repositories/metadata.rs @@ -10,7 +10,7 @@ use sea_orm::{ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, Qu use uuid::Uuid; use crate::db::entities::{book_metadata, prelude::*}; -use crate::utils::normalize_for_search; +use codex_utils::normalize_for_search; /// Repository for BookMetadata operations pub struct BookMetadataRepository; diff --git a/src/db/repositories/plugin_failures.rs b/src/db/repositories/plugin_failures.rs index 58d2edd4..3b315346 100644 --- a/src/db/repositories/plugin_failures.rs +++ b/src/db/repositories/plugin_failures.rs @@ -293,7 +293,7 @@ mod tests { use crate::db::entities::plugin_failures::error_codes; use crate::db::repositories::PluginsRepository; use crate::db::test_helpers::setup_test_db; - use crate::models::plugin::PluginScope; + use codex_models::plugin::PluginScope; use std::env; use tokio::time::sleep; diff --git a/src/db/repositories/plugins.rs b/src/db/repositories/plugins.rs index bf96eb93..42adc0f8 100644 --- a/src/db/repositories/plugins.rs +++ b/src/db/repositories/plugins.rs @@ -15,11 +15,11 @@ #![allow(dead_code)] use crate::db::entities::plugins::{self, Entity as Plugins, PluginPermission}; -use crate::models::plugin::{PluginManifest, PluginScope}; use crate::observability::repo::db_system_str; -use crate::utils::credential_encryption::CredentialEncryption; use anyhow::{Result, anyhow}; use chrono::Utc; +use codex_models::plugin::{PluginManifest, PluginScope}; +use codex_utils::credential_encryption::CredentialEncryption; use sea_orm::*; use uuid::Uuid; @@ -735,8 +735,8 @@ impl PluginsRepository { /// Returns an empty vector if no rules are configured or if parsing fails. pub fn get_search_preprocessing_rules( plugin: &plugins::Model, - ) -> Vec<crate::models::preprocessing::PreprocessingRule> { - use crate::models::preprocessing::parse_preprocessing_rules; + ) -> Vec<codex_models::preprocessing::PreprocessingRule> { + use codex_models::preprocessing::parse_preprocessing_rules; match parse_preprocessing_rules(plugin.search_preprocessing_rules.as_deref()) { Ok(rules) => rules, @@ -757,8 +757,8 @@ impl PluginsRepository { /// Returns None if no conditions are configured or if parsing fails. pub fn get_auto_match_conditions( plugin: &plugins::Model, - ) -> Option<crate::models::preprocessing::AutoMatchConditions> { - use crate::models::preprocessing::parse_auto_match_conditions; + ) -> Option<codex_models::preprocessing::AutoMatchConditions> { + use codex_models::preprocessing::parse_auto_match_conditions; match parse_auto_match_conditions(plugin.auto_match_conditions.as_deref()) { Ok(conditions) => conditions, diff --git a/src/db/repositories/read_progress.rs b/src/db/repositories/read_progress.rs index eda1d372..55f9a6ac 100644 --- a/src/db/repositories/read_progress.rs +++ b/src/db/repositories/read_progress.rs @@ -324,8 +324,8 @@ mod tests { BookRepository, LibraryRepository, SeriesRepository, UserRepository, }; use crate::db::test_helpers::setup_test_db; - use crate::models::ScanningStrategy; - use crate::utils::password; + use codex_models::ScanningStrategy; + use codex_utils::password; async fn create_test_user(db: &DatabaseConnection) -> users::Model { let password_hash = password::hash_password("password").unwrap(); diff --git a/src/db/repositories/release_ledger.rs b/src/db/repositories/release_ledger.rs index 5fe2132c..8440a481 100644 --- a/src/db/repositories/release_ledger.rs +++ b/src/db/repositories/release_ledger.rs @@ -18,7 +18,7 @@ use uuid::Uuid; use crate::db::entities::release_ledger::{ self, Entity as ReleaseLedger, Model as ReleaseLedgerRow, state, }; -use crate::models::release::{NumericSpan, normalize_spans, primary_value}; +use codex_models::release::{NumericSpan, normalize_spans, primary_value}; /// New-row payload. Keys plus payload fields. /// diff --git a/src/db/repositories/release_sources.rs b/src/db/repositories/release_sources.rs index 4719dde5..b6348078 100644 --- a/src/db/repositories/release_sources.rs +++ b/src/db/repositories/release_sources.rs @@ -20,7 +20,7 @@ use crate::db::entities::release_sources::{ self, Entity as ReleaseSources, Model as ReleaseSource, kind, plugin_id as source_plugin_id, }; use crate::db::repositories::plugins::PluginsRepository; -use crate::utils::cron::validate_cron_expression; +use codex_utils::cron::validate_cron_expression; /// Normalize a caller-supplied cron schedule: trim, treat empty as `None`, /// validate the parse, and return the trimmed string. Errors when the diff --git a/src/db/repositories/series.rs b/src/db/repositories/series.rs index 57733295..932a7b92 100644 --- a/src/db/repositories/series.rs +++ b/src/db/repositories/series.rs @@ -17,10 +17,10 @@ use crate::db::entities::{ book_metadata, books, prelude::*, read_progress, series, series_external_ratings, series_metadata, user_series_ratings, }; -use crate::models::sort::{SeriesSortField, SeriesSortParam, SortDirection}; use crate::observability::repo::db_system_str; -use crate::utils::normalize_for_search; use codex_events::{EntityChangeEvent, EntityEvent, EventBroadcaster}; +use codex_models::sort::{SeriesSortField, SeriesSortParam, SortDirection}; +use codex_utils::normalize_for_search; use std::sync::Arc; /// Options for querying series with filtering, sorting, and pagination @@ -2171,8 +2171,8 @@ impl SeriesRepository { pub async fn get_owned_release_keys_for_series( db: &DatabaseConnection, series_id: Uuid, - ) -> Result<crate::models::release::OwnedReleaseKeys> { - use crate::models::release::OwnedReleaseKeys; + ) -> Result<codex_models::release::OwnedReleaseKeys> { + use codex_models::release::OwnedReleaseKeys; #[derive(Debug, FromQueryResult)] struct KeyRow { @@ -3001,7 +3001,7 @@ mod tests { use crate::db::entities::users; use crate::db::repositories::{ReadProgressRepository, UserRepository}; - use crate::utils::password; + use codex_utils::password; let password_hash = password::hash_password("test123").unwrap(); let user = users::Model { diff --git a/src/db/repositories/series_metadata.rs b/src/db/repositories/series_metadata.rs index 3f1e6aa5..c089f8e6 100644 --- a/src/db/repositories/series_metadata.rs +++ b/src/db/repositories/series_metadata.rs @@ -11,8 +11,8 @@ use std::sync::Arc; use uuid::Uuid; use crate::db::entities::{series_metadata, series_metadata::Entity as SeriesMetadata}; -use crate::utils::normalize_for_search; use codex_events::{EntityChangeEvent, EntityEvent, EventBroadcaster}; +use codex_utils::normalize_for_search; /// Repository for series metadata operations pub struct SeriesMetadataRepository; diff --git a/src/db/repositories/task.rs b/src/db/repositories/task.rs index 0fb65434..7a7fc904 100644 --- a/src/db/repositories/task.rs +++ b/src/db/repositories/task.rs @@ -11,7 +11,7 @@ use uuid::Uuid; use crate::db::entities::{ book_metadata, books, libraries, prelude::*, series, series_metadata, tasks, }; -use crate::models::task::{DEFAULT_MAX_RESCHEDULES, TaskStats, TaskType}; +use codex_models::task::{DEFAULT_MAX_RESCHEDULES, TaskStats, TaskType}; /// Task row enriched with the resolved title of its target (book, series, or library). /// @@ -1138,7 +1138,7 @@ impl TaskRepository { /// Get queue statistics pub async fn get_stats(db: &DatabaseConnection) -> Result<TaskStats> { - use crate::models::task::TaskTypeStats; + use codex_models::task::TaskTypeStats; use std::collections::HashMap; // Get all tasks to calculate both aggregate and per-type stats diff --git a/src/db/repositories/user_plugins.rs b/src/db/repositories/user_plugins.rs index 9ede64f0..eeb2bdd6 100644 --- a/src/db/repositories/user_plugins.rs +++ b/src/db/repositories/user_plugins.rs @@ -16,9 +16,9 @@ #![allow(dead_code)] use crate::db::entities::user_plugins::{self, Entity as UserPlugins}; -use crate::utils::credential_encryption::CredentialEncryption; use anyhow::{Result, anyhow}; use chrono::{DateTime, Utc}; +use codex_utils::credential_encryption::CredentialEncryption; use sea_orm::*; use uuid::Uuid; diff --git a/src/db/repositories/user_preferences.rs b/src/db/repositories/user_preferences.rs index 5521a5f7..24793d0b 100644 --- a/src/db/repositories/user_preferences.rs +++ b/src/db/repositories/user_preferences.rs @@ -291,7 +291,7 @@ mod tests { use crate::db::entities::users; use crate::db::repositories::UserRepository; use crate::db::test_helpers::setup_test_db; - use crate::utils::password; + use codex_utils::password; async fn create_test_user(db: &DatabaseConnection) -> users::Model { let password_hash = password::hash_password("password").unwrap(); diff --git a/src/lib.rs b/src/lib.rs index f955bf49..c7ba6d49 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,18 +1,19 @@ pub mod api; pub mod db; -pub mod models; pub mod observability; -pub mod parsers; pub mod scanner; pub mod scheduler; pub mod search; pub mod services; pub mod tasks; -pub mod utils; pub mod web; -// Re-exports of workspace-leaf crates so existing `codex::config::*` and -// `codex::events::*` paths (used pervasively in integration tests) keep +// Re-exports of workspace-leaf crates so existing `codex::config::*`, +// `codex::events::*`, `codex::models::*`, `codex::parsers::*`, and +// `codex::utils::*` paths (used pervasively in integration tests) keep // resolving without churn. pub use codex_config as config; pub use codex_events as events; +pub use codex_models as models; +pub use codex_parsers as parsers; +pub use codex_utils as utils; diff --git a/src/main.rs b/src/main.rs index 5804fdd9..45ac6f49 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,15 +1,12 @@ mod api; mod commands; mod db; -mod models; mod observability; -mod parsers; mod scanner; mod scheduler; mod search; mod services; mod tasks; -mod utils; mod web; use clap::{Parser, Subcommand}; diff --git a/src/scanner/analyzer.rs b/src/scanner/analyzer.rs index f5213bcf..cfd723ff 100644 --- a/src/scanner/analyzer.rs +++ b/src/scanner/analyzer.rs @@ -1,12 +1,12 @@ -use crate::parsers::BookMetadata; -#[cfg(feature = "rar")] -use crate::parsers::cbr::CbrParser; -use crate::parsers::cbz::CbzParser; -use crate::parsers::epub::EpubParser; -use crate::parsers::pdf::PdfParser; -use crate::parsers::traits::FormatParser; use crate::scanner::detect_format; -use crate::utils::{CodexError, Result}; +use codex_parsers::BookMetadata; +#[cfg(feature = "rar")] +use codex_parsers::cbr::CbrParser; +use codex_parsers::cbz::CbzParser; +use codex_parsers::epub::EpubParser; +use codex_parsers::pdf::PdfParser; +use codex_parsers::traits::FormatParser; +use codex_utils::{CodexError, Result}; use std::path::Path; /// Analyze a file and extract metadata @@ -19,26 +19,26 @@ pub fn analyze_file<P: AsRef<Path>>(path: P) -> Result<BookMetadata> { // Select appropriate parser let metadata = match format { - crate::parsers::FileFormat::CBZ => { + codex_parsers::FileFormat::CBZ => { let parser = CbzParser::new(); parser.parse(path)? } #[cfg(feature = "rar")] - crate::parsers::FileFormat::CBR => { + codex_parsers::FileFormat::CBR => { let parser = CbrParser::new(); parser.parse(path)? } #[cfg(not(feature = "rar"))] - crate::parsers::FileFormat::CBR => { + codex_parsers::FileFormat::CBR => { return Err(CodexError::UnsupportedFormat( "CBR support requires the 'rar' feature to be enabled".to_string(), )); } - crate::parsers::FileFormat::EPUB => { + codex_parsers::FileFormat::EPUB => { let parser = EpubParser::new(); parser.parse(path)? } - crate::parsers::FileFormat::PDF => { + codex_parsers::FileFormat::PDF => { let parser = PdfParser::new(); parser.parse(path)? } diff --git a/src/scanner/analyzer_queue.rs b/src/scanner/analyzer_queue.rs index 767ed84b..5b02532d 100644 --- a/src/scanner/analyzer_queue.rs +++ b/src/scanner/analyzer_queue.rs @@ -14,16 +14,16 @@ use crate::db::repositories::{ BookExternalLinkRepository, BookMetadataRepository, BookRepository, ExternalLinkRepository, LibraryRepository, PageRepository, SeriesMetadataRepository, SeriesRepository, TaskRepository, }; -use crate::models::{BookStrategy, CalibreStrategyConfig, NumberStrategy, SeriesStrategy}; -use crate::parsers::opf; use crate::scanner::analyze_file; use crate::scanner::strategies::{ BookMetadata, BookNamingContext, NumberContext, NumberMetadata, create_book_strategy, create_number_strategy, }; use crate::tasks::types::TaskType; -use crate::utils::normalize_for_search; use codex_events::EventBroadcaster; +use codex_models::{BookStrategy, CalibreStrategyConfig, NumberStrategy, SeriesStrategy}; +use codex_parsers::opf; +use codex_utils::normalize_for_search; use super::types::ScanProgress; @@ -156,7 +156,7 @@ async fn analyze_single_book( // Compute full hash to verify the file actually changed let path_clone = path.clone(); let current_full_hash = tokio::task::spawn_blocking(move || { - use crate::utils::hasher::hash_file; + use codex_utils::hasher::hash_file; hash_file(&path_clone) }) .await @@ -167,7 +167,7 @@ async fn analyze_single_book( // Update partial_hash to match current state and skip analysis let path_clone2 = path.clone(); let current_partial_hash = tokio::task::spawn_blocking(move || { - use crate::utils::hasher::hash_file_partial; + use codex_utils::hasher::hash_file_partial; hash_file_partial(&path_clone2) }) .await @@ -265,7 +265,7 @@ async fn analyze_single_book( // Compute partial hash to keep both hashes in sync let path_clone2 = path.clone(); let partial_hash = tokio::task::spawn_blocking(move || { - use crate::utils::hasher::hash_file_partial; + use codex_utils::hasher::hash_file_partial; hash_file_partial(&path_clone2) }) .await @@ -299,7 +299,7 @@ async fn analyze_single_book( // Recompute KOReader hash during analysis let path_clone = path.clone(); let koreader_hash = tokio::task::spawn_blocking(move || { - crate::utils::hasher::hash_file_koreader(&path_clone).ok() + codex_utils::hasher::hash_file_koreader(&path_clone).ok() }) .await .unwrap_or(None); @@ -941,7 +941,7 @@ async fn analyze_single_book( let series_json_path = path.parent().map(|p| p.join("series.json")); if let Some(sjp) = series_json_path { let sj_result = tokio::task::spawn_blocking(move || { - crate::parsers::series_json::parse_series_json_file(&sjp) + codex_parsers::series_json::parse_series_json_file(&sjp) }) .await .map_err(|e| anyhow::anyhow!("Failed to spawn series.json parse task: {}", e))?; @@ -1100,7 +1100,7 @@ struct BookClassification { async fn resolve_book_classification( db: &DatabaseConnection, book: &books::Model, - file_metadata: &crate::parsers::BookMetadata, + file_metadata: &codex_parsers::BookMetadata, book_number: Option<f32>, ) -> BookClassification { let Ok(Some(library)) = LibraryRepository::get_by_id(db, book.library_id).await else { @@ -1149,7 +1149,7 @@ async fn resolve_book_classification( async fn resolve_book_title( db: &DatabaseConnection, book: &books::Model, - file_metadata: &crate::parsers::BookMetadata, + file_metadata: &codex_parsers::BookMetadata, book_number: Option<f32>, ) -> String { // Get library to determine book naming strategy @@ -1225,7 +1225,7 @@ fn filename_fallback(file_name: &str) -> String { async fn resolve_book_number( db: &DatabaseConnection, book: &books::Model, - _file_metadata: &crate::parsers::BookMetadata, + _file_metadata: &codex_parsers::BookMetadata, metadata_number: Option<f32>, ) -> Option<f32> { // Get library to determine number strategy @@ -1290,7 +1290,7 @@ async fn get_book_position_in_series( // Sort by filename using natural sort order so "Vol. 2" comes before "Vol. 10" let mut sorted_names: Vec<&str> = books.iter().map(|b| b.file_name.as_str()).collect(); - sorted_names.sort_by(|a, b| crate::utils::natural_cmp_filename(a, b)); + sorted_names.sort_by(|a, b| codex_utils::natural_cmp_filename(a, b)); // Find position of this book (1-indexed) let position = sorted_names @@ -1361,7 +1361,7 @@ pub async fn renumber_series_books( // Sort active filenames using natural sort to determine positions let mut sorted_filenames: Vec<&str> = active_books.iter().map(|b| b.file_name.as_str()).collect(); - sorted_filenames.sort_by(|a, b| crate::utils::natural_cmp_filename(a, b)); + sorted_filenames.sort_by(|a, b| codex_utils::natural_cmp_filename(a, b)); // Build a position map: filename -> 1-indexed position let position_map: std::collections::HashMap<&str, usize> = sorted_filenames diff --git a/src/scanner/detector.rs b/src/scanner/detector.rs index c028b055..195ba2cc 100644 --- a/src/scanner/detector.rs +++ b/src/scanner/detector.rs @@ -1,4 +1,4 @@ -use crate::parsers::FileFormat; +use codex_parsers::FileFormat; use std::fs::File; use std::io::Read; use std::path::Path; diff --git a/src/scanner/library_scanner.rs b/src/scanner/library_scanner.rs index 003fa560..e2c97b94 100644 --- a/src/scanner/library_scanner.rs +++ b/src/scanner/library_scanner.rs @@ -18,9 +18,9 @@ use crate::db::entities::{books, series}; use crate::db::repositories::{ BookRepository, LibraryRepository, SeriesRepository, TaskRepository, }; -use crate::models::SeriesStrategy; use crate::tasks::types::TaskType; use codex_events::{EventBroadcaster, TaskProgressEvent}; +use codex_models::SeriesStrategy; use super::strategies::{DetectedSeries, create_strategy}; use super::types::{ScanMode, ScanProgress, ScanResult, ScanStatus, ScannerConfig}; @@ -878,7 +878,7 @@ async fn hash_file_with_metadata(path: PathBuf) -> Result<FileHashResult> { // Calculate current partial hash and KOReader hash (blocking I/O) let path_clone = path.clone(); let (current_partial_hash, koreader_hash) = tokio::task::spawn_blocking(move || { - use crate::utils::hasher::{hash_file_koreader, hash_file_partial}; + use codex_utils::hasher::{hash_file_koreader, hash_file_partial}; let partial = hash_file_partial(&path_clone)?; let koreader = hash_file_koreader(&path_clone).ok(); Ok::<_, std::io::Error>((partial, koreader)) diff --git a/src/scanner/strategies/book/custom.rs b/src/scanner/strategies/book/custom.rs index 4b88483f..24e56050 100644 --- a/src/scanner/strategies/book/custom.rs +++ b/src/scanner/strategies/book/custom.rs @@ -5,7 +5,7 @@ use regex::Regex; -use crate::models::{BookStrategy, CustomBookConfig}; +use codex_models::{BookStrategy, CustomBookConfig}; use super::{ BookMetadata, BookNamingContext, BookNamingStrategy, create_book_strategy, diff --git a/src/scanner/strategies/book/filename.rs b/src/scanner/strategies/book/filename.rs index 0a77c548..07543ad7 100644 --- a/src/scanner/strategies/book/filename.rs +++ b/src/scanner/strategies/book/filename.rs @@ -8,7 +8,7 @@ use lazy_static::lazy_static; use regex::Regex; -use crate::models::BookStrategy; +use codex_models::BookStrategy; use super::{BookMetadata, BookMetadataStrategy, BookNamingContext, filename_without_extension}; diff --git a/src/scanner/strategies/book/metadata_first.rs b/src/scanner/strategies/book/metadata_first.rs index f58ad894..a02dd006 100644 --- a/src/scanner/strategies/book/metadata_first.rs +++ b/src/scanner/strategies/book/metadata_first.rs @@ -2,7 +2,7 @@ //! //! Uses metadata title if present, falls back to filename -use crate::models::BookStrategy; +use codex_models::BookStrategy; use super::{BookMetadata, BookNamingContext, BookNamingStrategy, filename_without_extension}; diff --git a/src/scanner/strategies/book/mod.rs b/src/scanner/strategies/book/mod.rs index be3e5895..074b2272 100644 --- a/src/scanner/strategies/book/mod.rs +++ b/src/scanner/strategies/book/mod.rs @@ -22,7 +22,7 @@ pub use metadata_first::MetadataFirstStrategy; pub use series_name::SeriesNameStrategy; pub use smart::SmartStrategy; -use crate::models::{BookStrategy, CustomBookConfig, SmartBookConfig}; +use codex_models::{BookStrategy, CustomBookConfig, SmartBookConfig}; /// Context for resolving book metadata #[derive(Debug, Clone)] diff --git a/src/scanner/strategies/book/series_name.rs b/src/scanner/strategies/book/series_name.rs index 999e977f..234d5ef5 100644 --- a/src/scanner/strategies/book/series_name.rs +++ b/src/scanner/strategies/book/series_name.rs @@ -5,7 +5,7 @@ use lazy_static::lazy_static; use regex::Regex; -use crate::models::BookStrategy; +use codex_models::BookStrategy; use super::{BookMetadata, BookNamingContext, BookNamingStrategy, filename_without_extension}; diff --git a/src/scanner/strategies/book/smart.rs b/src/scanner/strategies/book/smart.rs index 8debebb7..63108568 100644 --- a/src/scanner/strategies/book/smart.rs +++ b/src/scanner/strategies/book/smart.rs @@ -6,7 +6,7 @@ use lazy_static::lazy_static; use regex::Regex; -use crate::models::{BookStrategy, SmartBookConfig}; +use codex_models::{BookStrategy, SmartBookConfig}; use super::{ BookMetadata, BookNamingContext, BookNamingStrategy, FilenameStrategy, diff --git a/src/scanner/strategies/number/file_order.rs b/src/scanner/strategies/number/file_order.rs index 7192f4e6..2e7632e3 100644 --- a/src/scanner/strategies/number/file_order.rs +++ b/src/scanner/strategies/number/file_order.rs @@ -3,7 +3,7 @@ //! Book number = position in alphabetically sorted file list within series. //! This is the default strategy and matches Komga behavior. -use crate::models::NumberStrategy; +use codex_models::NumberStrategy; use super::{BookNumberStrategy, NumberContext, NumberMetadata}; diff --git a/src/scanner/strategies/number/filename.rs b/src/scanner/strategies/number/filename.rs index 4389d3a2..414009a1 100644 --- a/src/scanner/strategies/number/filename.rs +++ b/src/scanner/strategies/number/filename.rs @@ -6,7 +6,7 @@ use lazy_static::lazy_static; use regex::Regex; -use crate::models::NumberStrategy; +use codex_models::NumberStrategy; use super::{BookNumberStrategy, NumberContext, NumberMetadata}; diff --git a/src/scanner/strategies/number/metadata.rs b/src/scanner/strategies/number/metadata.rs index 71ea157c..a2f3b8db 100644 --- a/src/scanner/strategies/number/metadata.rs +++ b/src/scanner/strategies/number/metadata.rs @@ -3,7 +3,7 @@ //! Uses ComicInfo <Number> field only, no fallback. //! Returns None if no metadata number is available. -use crate::models::NumberStrategy; +use codex_models::NumberStrategy; use super::{BookNumberStrategy, NumberContext, NumberMetadata}; diff --git a/src/scanner/strategies/number/mod.rs b/src/scanner/strategies/number/mod.rs index 6d4f0d97..810c8aef 100644 --- a/src/scanner/strategies/number/mod.rs +++ b/src/scanner/strategies/number/mod.rs @@ -18,7 +18,7 @@ pub use filename::FilenameStrategy; pub use metadata::MetadataStrategy; pub use smart::SmartStrategy; -use crate::models::NumberStrategy; +use codex_models::NumberStrategy; /// Context for resolving book numbers #[derive(Debug, Clone)] diff --git a/src/scanner/strategies/number/smart.rs b/src/scanner/strategies/number/smart.rs index bf739f5c..4b8e6c60 100644 --- a/src/scanner/strategies/number/smart.rs +++ b/src/scanner/strategies/number/smart.rs @@ -3,7 +3,7 @@ //! Fallback chain: metadata → filename patterns → file order. //! This provides the best coverage by using the best available information. -use crate::models::NumberStrategy; +use codex_models::NumberStrategy; use super::{BookNumberStrategy, NumberContext, NumberMetadata, filename::FilenameStrategy}; diff --git a/src/scanner/strategies/series/calibre.rs b/src/scanner/strategies/series/calibre.rs index 16bcbc0e..cddd8b0d 100644 --- a/src/scanner/strategies/series/calibre.rs +++ b/src/scanner/strategies/series/calibre.rs @@ -10,8 +10,8 @@ use std::collections::HashMap; use std::path::{Path, PathBuf}; use tracing::debug; -use crate::models::{CalibreSeriesMode, CalibreStrategyConfig, SeriesStrategy}; -use crate::parsers::opf; +use codex_models::{CalibreSeriesMode, CalibreStrategyConfig, SeriesStrategy}; +use codex_parsers::opf; use super::super::common::{DetectedBook, DetectedSeries, SeriesMetadata}; use super::ScanningStrategyImpl; diff --git a/src/scanner/strategies/series/custom.rs b/src/scanner/strategies/series/custom.rs index a118cf5b..46558d85 100644 --- a/src/scanner/strategies/series/custom.rs +++ b/src/scanner/strategies/series/custom.rs @@ -13,7 +13,7 @@ use regex::Regex; use std::collections::HashMap; use std::path::{Path, PathBuf}; -use crate::models::{CustomStrategyConfig, SeriesStrategy}; +use codex_models::{CustomStrategyConfig, SeriesStrategy}; use super::super::common::{DetectedBook, DetectedSeries, SeriesMetadata}; use super::ScanningStrategyImpl; diff --git a/src/scanner/strategies/series/flat.rs b/src/scanner/strategies/series/flat.rs index 976fb7af..d36e49fc 100644 --- a/src/scanner/strategies/series/flat.rs +++ b/src/scanner/strategies/series/flat.rs @@ -9,7 +9,7 @@ use regex::Regex; use std::collections::HashMap; use std::path::{Path, PathBuf}; -use crate::models::{FlatStrategyConfig, SeriesStrategy}; +use codex_models::{FlatStrategyConfig, SeriesStrategy}; use super::super::common::{DetectedBook, DetectedSeries}; use super::ScanningStrategyImpl; diff --git a/src/scanner/strategies/series/mod.rs b/src/scanner/strategies/series/mod.rs index c3fd4664..74d23ec9 100644 --- a/src/scanner/strategies/series/mod.rs +++ b/src/scanner/strategies/series/mod.rs @@ -25,7 +25,7 @@ use anyhow::Result; use std::collections::HashMap; use std::path::{Path, PathBuf}; -use crate::models::{ +use codex_models::{ CalibreStrategyConfig, CustomStrategyConfig, FlatStrategyConfig, PublisherHierarchyConfig, SeriesStrategy, }; diff --git a/src/scanner/strategies/series/publisher_hierarchy.rs b/src/scanner/strategies/series/publisher_hierarchy.rs index faf63afc..3cad9493 100644 --- a/src/scanner/strategies/series/publisher_hierarchy.rs +++ b/src/scanner/strategies/series/publisher_hierarchy.rs @@ -8,7 +8,7 @@ use anyhow::Result; use std::collections::HashMap; use std::path::{Path, PathBuf}; -use crate::models::{PublisherHierarchyConfig, SeriesStrategy}; +use codex_models::{PublisherHierarchyConfig, SeriesStrategy}; use super::super::common::{DetectedBook, DetectedSeries, SeriesMetadata}; use super::ScanningStrategyImpl; diff --git a/src/scanner/strategies/series/series_volume.rs b/src/scanner/strategies/series/series_volume.rs index 08b34547..107d0106 100644 --- a/src/scanner/strategies/series/series_volume.rs +++ b/src/scanner/strategies/series/series_volume.rs @@ -9,7 +9,7 @@ use anyhow::Result; use std::collections::HashMap; use std::path::{Path, PathBuf}; -use crate::models::SeriesStrategy; +use codex_models::SeriesStrategy; use super::super::common::{DetectedBook, DetectedSeries}; use super::ScanningStrategyImpl; diff --git a/src/scanner/strategies/series/series_volume_chapter.rs b/src/scanner/strategies/series/series_volume_chapter.rs index c55e4bde..64c02c79 100644 --- a/src/scanner/strategies/series/series_volume_chapter.rs +++ b/src/scanner/strategies/series/series_volume_chapter.rs @@ -10,7 +10,7 @@ use regex::Regex; use std::collections::HashMap; use std::path::{Path, PathBuf}; -use crate::models::SeriesStrategy; +use codex_models::SeriesStrategy; use super::super::common::{DetectedBook, DetectedSeries}; use super::ScanningStrategyImpl; diff --git a/src/scheduler/mod.rs b/src/scheduler/mod.rs index 3f01c2f4..1a136ada 100644 --- a/src/scheduler/mod.rs +++ b/src/scheduler/mod.rs @@ -13,7 +13,7 @@ use crate::scanner::{ScanMode, ScanningConfig}; use crate::services::library_jobs::{LibraryJobConfig, parse_job_config}; use crate::services::settings::SettingsService; use crate::tasks::types::TaskType; -use crate::utils::cron::{normalize_cron_expression, parse_timezone}; +use codex_utils::cron::{normalize_cron_expression, parse_timezone}; /// Generic scheduler for managing scheduled tasks (library scans, deduplication, etc.) pub struct Scheduler { @@ -809,8 +809,8 @@ mod tests { use super::*; use crate::db::repositories::LibraryRepository; use crate::db::test_helpers::setup_test_db; - use crate::models::ScanningStrategy; use crate::tasks::types::TaskType; + use codex_models::ScanningStrategy; #[test] fn test_scheduler_can_be_created() { diff --git a/src/scheduler/release_sources.rs b/src/scheduler/release_sources.rs index 8563b31f..889207a4 100644 --- a/src/scheduler/release_sources.rs +++ b/src/scheduler/release_sources.rs @@ -27,7 +27,7 @@ use crate::db::repositories::{ReleaseSourceRepository, TaskRepository}; use crate::services::release::schedule::{read_default_cron_schedule, resolve_cron_schedule}; use crate::services::settings::SettingsService; use crate::tasks::types::TaskType; -use crate::utils::cron::normalize_cron_expression; +use codex_utils::cron::normalize_cron_expression; /// Tracks scheduler-registered jobs per source row so we can reconcile. #[derive(Debug, Default)] diff --git a/src/search/index.rs b/src/search/index.rs index bb7236e9..114e2b60 100644 --- a/src/search/index.rs +++ b/src/search/index.rs @@ -13,7 +13,7 @@ use nucleo_matcher::{Config, Matcher, Utf32String}; use parking_lot::{Mutex, RwLock}; use uuid::Uuid; -use crate::utils::normalize_for_search; +use codex_utils::normalize_for_search; /// Source strings for a series entry, retained so we can rebuild the /// haystack on an incremental update (Phase 2) without round-tripping diff --git a/src/services/auth_tracking.rs b/src/services/auth_tracking.rs index 71fe4e67..5098683b 100644 --- a/src/services/auth_tracking.rs +++ b/src/services/auth_tracking.rs @@ -193,7 +193,7 @@ mod tests { use crate::db::entities::{api_keys, users}; use crate::db::repositories::{ApiKeyRepository, UserRepository}; use crate::db::test_helpers::setup_test_db; - use crate::utils::password; + use codex_utils::password; use std::time::Duration; async fn create_test_user(db: &DatabaseConnection) -> users::Model { diff --git a/src/services/cleanup_subscriber.rs b/src/services/cleanup_subscriber.rs index c26b3c2c..9493e08f 100644 --- a/src/services/cleanup_subscriber.rs +++ b/src/services/cleanup_subscriber.rs @@ -9,8 +9,8 @@ use tokio::sync::broadcast; use tracing::{debug, error, info, warn}; use crate::db::repositories::TaskRepository; -use crate::models::task::TaskType; use codex_events::{EntityChangeEvent, EntityEvent, EventBroadcaster}; +use codex_models::task::TaskType; /// Service that subscribes to entity events and triggers file cleanup tasks pub struct CleanupEventSubscriber { @@ -190,9 +190,9 @@ impl CleanupEventSubscriber { mod tests { use super::*; use crate::db::test_helpers::create_test_db; - use crate::models::task::TaskType; use chrono::Utc; use codex_events::EventBroadcaster; + use codex_models::task::TaskType; use uuid::Uuid; #[tokio::test] diff --git a/src/services/filter.rs b/src/services/filter.rs index de38537d..d660f027 100644 --- a/src/services/filter.rs +++ b/src/services/filter.rs @@ -5,11 +5,11 @@ #![allow(dead_code)] use crate::db::repositories::{GenreRepository, TagRepository}; -use crate::models::filter::{ +use anyhow::Result; +use codex_models::filter::{ BookCondition, BoolOperator, DateOperator, FieldOperator, NumberOperator, SeriesCondition, UuidOperator, }; -use anyhow::Result; use sea_orm::DatabaseConnection; use std::collections::HashSet; use std::future::Future; @@ -2515,7 +2515,7 @@ impl FilterService { #[cfg(test)] mod tests { use super::*; - use crate::models::filter::{BookCondition, FieldOperator, SeriesCondition, UuidOperator}; + use codex_models::filter::{BookCondition, FieldOperator, SeriesCondition, UuidOperator}; // Unit tests for condition building and basic logic diff --git a/src/services/library_jobs/validation.rs b/src/services/library_jobs/validation.rs index ee8dbdb0..c0c85c8e 100644 --- a/src/services/library_jobs/validation.rs +++ b/src/services/library_jobs/validation.rs @@ -14,7 +14,7 @@ use std::str::FromStr; use crate::db::repositories::PluginsRepository; use crate::services::metadata::FieldGroup; -use crate::utils::cron::{validate_cron_expression, validate_timezone}; +use codex_utils::cron::{validate_cron_expression, validate_timezone}; use super::types::{ LibraryJobConfig, MAX_CONCURRENCY_HARD_CAP, MetadataRefreshJobConfig, RefreshScope, diff --git a/src/services/metadata/cover.rs b/src/services/metadata/cover.rs index c2082edb..3588821a 100644 --- a/src/services/metadata/cover.rs +++ b/src/services/metadata/cover.rs @@ -12,9 +12,9 @@ use uuid::Uuid; use crate::db::repositories::{ BookCoversRepository, SeriesCoversRepository, SeriesRepository, TaskRepository, }; -use crate::models::task::TaskType; use crate::services::ThumbnailService; use codex_events::{EntityChangeEvent, EntityEvent, EntityType, EventBroadcaster}; +use codex_models::task::TaskType; /// Service for downloading and applying cover images to series. pub struct CoverService; @@ -75,7 +75,7 @@ impl CoverService { image::load_from_memory(&image_data).context("Invalid image file")?; // Compute hash of image data for deduplication - let image_hash = crate::utils::hasher::hash_bytes(&image_data); + let image_hash = codex_utils::hasher::hash_bytes(&image_data); let short_hash = &image_hash[..16]; // Create covers directory within uploads dir if it doesn't exist @@ -185,7 +185,7 @@ impl CoverService { image::load_from_memory(&image_data).context("Invalid image file")?; // Compute hash of image data for deduplication - let image_hash = crate::utils::hasher::hash_bytes(&image_data); + let image_hash = codex_utils::hasher::hash_bytes(&image_data); let short_hash = &image_hash[..16]; // Create covers directory within uploads dir if it doesn't exist diff --git a/src/services/metadata/preprocessing/types.rs b/src/services/metadata/preprocessing/types.rs index 955d21d8..d781064f 100644 --- a/src/services/metadata/preprocessing/types.rs +++ b/src/services/metadata/preprocessing/types.rs @@ -1,8 +1,8 @@ //! Re-export of preprocessing value types. //! -//! The canonical home is [`crate::models::preprocessing`] so the db layer +//! The canonical home is [`codex_models::preprocessing`] so the db layer //! can speak these types without depending on services. This module keeps //! the historical `services::metadata::preprocessing::types::*` path alive //! for the local processing logic in sibling modules. -pub use crate::models::preprocessing::*; +pub use codex_models::preprocessing::*; diff --git a/src/services/mod.rs b/src/services/mod.rs index 2b3e7aa8..7d6d1136 100644 --- a/src/services/mod.rs +++ b/src/services/mod.rs @@ -48,8 +48,8 @@ pub use task_listener::TaskListener; pub use task_metrics::TaskMetricsService; pub use thumbnail::ThumbnailService; -// Historical alias. The canonical location is `crate::utils::credential_encryption`. +// Historical alias. The canonical location is `codex_utils::credential_encryption`. #[allow(unused_imports)] -pub use crate::utils::credential_encryption::CredentialEncryption; +pub use codex_utils::credential_encryption::CredentialEncryption; pub use plugin_file_storage::{PluginCleanupStats, PluginFileStorage, PluginStorageStats}; pub use plugin_metrics::{PluginHealthStatus, PluginMetricsService}; diff --git a/src/services/plugin/protocol.rs b/src/services/plugin/protocol.rs index 77638f43..99353e6c 100644 --- a/src/services/plugin/protocol.rs +++ b/src/services/plugin/protocol.rs @@ -290,11 +290,11 @@ pub mod methods { // Plugin Manifest Types (re-exported from models::plugin) // ============================================================================= // -// These types live in `crate::models::plugin` so the db layer can speak the +// These types live in `codex_models::plugin` so the db layer can speak the // plugin protocol vocabulary without depending on services. The re-exports // preserve historical paths used throughout the plugin codebase. #[allow(unused_imports)] -pub use crate::models::plugin::{ +pub use codex_models::plugin::{ CredentialField, CredentialType, MetadataContentType, OAuthConfig, PluginCapabilities, PluginManifest, PluginManifestType, PluginScope, ReleaseSourceCapability, ReleaseSourceKind, }; diff --git a/src/services/read_progress.rs b/src/services/read_progress.rs index 0662b35d..79c95e70 100644 --- a/src/services/read_progress.rs +++ b/src/services/read_progress.rs @@ -217,9 +217,9 @@ mod tests { BookRepository, LibraryRepository, ReadProgressRepository, SeriesRepository, UserRepository, }; use crate::db::test_helpers::setup_test_db; - use crate::models::ScanningStrategy; - use crate::utils::password; use chrono::Utc; + use codex_models::ScanningStrategy; + use codex_utils::password; use std::time::Duration; async fn create_test_user(db: &DatabaseConnection) -> users::Model { diff --git a/src/services/release/auto_ignore.rs b/src/services/release/auto_ignore.rs index 7780b3e4..c6a318d8 100644 --- a/src/services/release/auto_ignore.rs +++ b/src/services/release/auto_ignore.rs @@ -19,8 +19,8 @@ //! an owned `(Some(3), None)`; a release for "Ch 12" matches an owned //! `(_, Some(12))` regardless of volume. -use crate::models::release::NumericSpan; -pub use crate::models::release::OwnedReleaseKeys; +use codex_models::release::NumericSpan; +pub use codex_models::release::OwnedReleaseKeys; /// True when the user owns *every* item the release covers. /// diff --git a/src/services/release/candidate.rs b/src/services/release/candidate.rs index 9a339336..74b920d4 100644 --- a/src/services/release/candidate.rs +++ b/src/services/release/candidate.rs @@ -8,10 +8,10 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use uuid::Uuid; -// `NumericSpan` and the span helpers live in `crate::models::release` so the +// `NumericSpan` and the span helpers live in `codex_models::release` so the // db layer can consume them without importing services. #[allow(unused_imports)] -pub use crate::models::release::{NumericSpan, normalize_spans, primary_value}; +pub use codex_models::release::{NumericSpan, normalize_spans, primary_value}; /// A release candidate emitted by a `release_source` plugin. /// diff --git a/src/services/release/schedule.rs b/src/services/release/schedule.rs index 001f15c0..6d42555a 100644 --- a/src/services/release/schedule.rs +++ b/src/services/release/schedule.rs @@ -42,7 +42,7 @@ pub async fn read_default_cron_schedule(settings: &SettingsService) -> String { /// inheriting). `server_default` is the resolved server-wide default. The /// returned string is the raw 5- or 6-field cron expression; callers /// normalize to the 6-field tokio-cron-scheduler format via -/// [`crate::utils::cron::normalize_cron_expression`]. +/// [`codex_utils::cron::normalize_cron_expression`]. pub fn resolve_cron_schedule(per_source: Option<&str>, server_default: &str) -> String { if let Some(cron) = per_source.map(str::trim).filter(|s| !s.is_empty()) { cron.to_string() diff --git a/src/services/thumbnail.rs b/src/services/thumbnail.rs index 0761d35c..ba56a66b 100644 --- a/src/services/thumbnail.rs +++ b/src/services/thumbnail.rs @@ -1163,11 +1163,11 @@ impl ThumbnailService { // Use the appropriate parser extraction function based on format // Enable fallback mode to skip corrupted images let image_data = match book.format.to_uppercase().as_str() { - "CBZ" => crate::parsers::cbz::extract_page_from_cbz_with_fallback(path, 1, true)?, + "CBZ" => codex_parsers::cbz::extract_page_from_cbz_with_fallback(path, 1, true)?, #[cfg(feature = "rar")] - "CBR" => crate::parsers::cbr::extract_page_from_cbr_with_fallback(path, 1, true)?, - "EPUB" => crate::parsers::epub::extract_page_from_epub_with_fallback(path, 1, true)?, - "PDF" => crate::parsers::pdf::extract_page_from_pdf(path, 1)?, + "CBR" => codex_parsers::cbr::extract_page_from_cbr_with_fallback(path, 1, true)?, + "EPUB" => codex_parsers::epub::extract_page_from_epub_with_fallback(path, 1, true)?, + "PDF" => codex_parsers::pdf::extract_page_from_pdf(path, 1)?, _ => { return Err(anyhow!( "Unsupported format for thumbnail generation: {}", diff --git a/src/tasks/error.rs b/src/tasks/error.rs index c40f776c..a4379180 100644 --- a/src/tasks/error.rs +++ b/src/tasks/error.rs @@ -14,11 +14,11 @@ use crate::services::plugin::PluginManagerError; -// Re-exported from `crate::models::task` so existing call sites work and the +// Re-exported from `codex_models::task` so existing call sites work and the // canonical constants live in the shared `models` layer (avoids db -> tasks // imports for what is really a value type). #[allow(unused_imports)] -pub use crate::models::task::{DEFAULT_MAX_RESCHEDULES, DEFAULT_RATE_LIMIT_RETRY_SECONDS}; +pub use codex_models::task::{DEFAULT_MAX_RESCHEDULES, DEFAULT_RATE_LIMIT_RETRY_SECONDS}; /// Trait for errors that represent rate limiting /// diff --git a/src/tasks/handlers/generate_series_thumbnail.rs b/src/tasks/handlers/generate_series_thumbnail.rs index 2cfd1b8b..09d620a6 100644 --- a/src/tasks/handlers/generate_series_thumbnail.rs +++ b/src/tasks/handlers/generate_series_thumbnail.rs @@ -273,11 +273,11 @@ async fn extract_page_image( // Use spawn_blocking for CPU-intensive file parsing operations tokio::task::spawn_blocking(move || match format.as_str() { - "CBZ" => crate::parsers::cbz::extract_page_from_cbz(&path, page_number), + "CBZ" => codex_parsers::cbz::extract_page_from_cbz(&path, page_number), #[cfg(feature = "rar")] - "CBR" => crate::parsers::cbr::extract_page_from_cbr(&path, page_number), - "EPUB" => crate::parsers::epub::extract_page_from_epub(&path, page_number), - "PDF" => crate::parsers::pdf::extract_page_from_pdf(&path, page_number), + "CBR" => codex_parsers::cbr::extract_page_from_cbr(&path, page_number), + "EPUB" => codex_parsers::epub::extract_page_from_epub(&path, page_number), + "PDF" => codex_parsers::pdf::extract_page_from_pdf(&path, page_number), _ => anyhow::bail!("Unsupported format: {}", format), }) .await diff --git a/src/tasks/types.rs b/src/tasks/types.rs index 62bfc714..62cee098 100644 --- a/src/tasks/types.rs +++ b/src/tasks/types.rs @@ -1,8 +1,8 @@ //! Re-export of task value types. //! -//! The canonical home is [`crate::models::task`]. This module keeps the +//! The canonical home is [`codex_models::task`]. This module keeps the //! `crate::tasks::types::*` path working for tests and downstream code while //! the data shapes live in `models` so non-tasks layers can speak them //! without depending on the tasks layer. -pub use crate::models::task::*; +pub use codex_models::task::*; From bffecc545bb31eec048e2881f86f560a574efd29 Mon Sep 17 00:00:00 2001 From: Sylvain Cau <ashdevfr@gmail.com> Date: Sat, 23 May 2026 17:36:14 -0700 Subject: [PATCH 04/14] refactor(workspace): extract codex-db crate Moves the SeaORM entities, repositories, connection pool, and test helpers out of the monolithic codex crate into a new sibling workspace member. - crates/codex-db: depends on codex-config, codex-events, codex-models, codex-utils, plus sea-orm, sea-orm-migration, and the migration crate. Root keeps sea-orm (direct call sites in services/api/scanner) and the migration crate (oidc handler runs Migrator::up) but drops sea-orm-migration, which is now only used transitively through codex-db. - New test-utils feature on codex-db gates test_helpers behind cfg(any(test, feature = "test-utils")) so downstream crates can opt in via a dev-dependency feature flag without dragging tempfile or SQLite fixture plumbing into release builds. Root crate's dev-deps enable the feature. - The observability::repo module (db_system_str helper that maps a SeaORM backend to the OTel db.system attribute) moves into codex-db::trace along with its tracing-subscriber tests. The function only consumes SeaORM types, so the observability home was historical accident; the move breaks the otherwise circular db -> observability edge. Consumers (api, services, tasks, scanner, scheduler, search, commands, observability) now reference codex_db::* instead of crate::db::*. The root codex crate keeps `pub use codex_db as db` so integration tests that import via codex::db::* continue to resolve. cargo nextest run defaults to the current package, which was silently skipping leaf-crate tests added by previous extractions; the Makefile's test* targets now pass --workspace to cover every crate's suite. Cold build drops ~5%, warm rebuild after touching an API handler drops ~20% vs the previous workspace shape. Editing src/api/ recompiles only the root crate; codex-db and the rest of the leaf crates stay cached. --- Cargo.lock | 28 +++++- Cargo.toml | 14 ++- Makefile | 8 +- crates/codex-db/Cargo.toml | 61 ++++++++++++ {src/db => crates/codex-db/src}/connection.rs | 2 +- .../codex-db/src}/entities/api_keys.rs | 0 .../codex-db/src}/entities/book_covers.rs | 0 .../codex-db/src}/entities/book_duplicates.rs | 0 .../codex-db/src}/entities/book_error.rs | 0 .../src}/entities/book_external_ids.rs | 0 .../src}/entities/book_external_links.rs | 0 .../codex-db/src}/entities/book_genres.rs | 0 .../codex-db/src}/entities/book_metadata.rs | 0 .../codex-db/src}/entities/book_tags.rs | 0 .../codex-db/src}/entities/books.rs | 0 .../entities/email_verification_tokens.rs | 0 .../codex-db/src}/entities/filter_presets.rs | 0 .../codex-db/src}/entities/genres.rs | 0 .../codex-db/src}/entities/libraries.rs | 0 .../codex-db/src}/entities/library_jobs.rs | 0 .../src}/entities/metadata_sources.rs | 0 .../codex-db/src}/entities/mod.rs | 0 .../src}/entities/oidc_connections.rs | 0 .../codex-db/src}/entities/pages.rs | 0 .../codex-db/src}/entities/plugin_failures.rs | 0 .../codex-db/src}/entities/plugins.rs | 0 .../codex-db/src}/entities/prelude.rs | 0 .../codex-db/src}/entities/read_progress.rs | 0 .../codex-db/src}/entities/refresh_tokens.rs | 0 .../codex-db/src}/entities/release_ledger.rs | 0 .../codex-db/src}/entities/release_sources.rs | 2 +- .../codex-db/src}/entities/series.rs | 0 .../codex-db/src}/entities/series_aliases.rs | 0 .../src}/entities/series_alternate_titles.rs | 0 .../codex-db/src}/entities/series_covers.rs | 0 .../src}/entities/series_duplicates.rs | 0 .../codex-db/src}/entities/series_exports.rs | 0 .../src}/entities/series_external_ids.rs | 0 .../src}/entities/series_external_links.rs | 0 .../src}/entities/series_external_ratings.rs | 0 .../codex-db/src}/entities/series_genres.rs | 0 .../codex-db/src}/entities/series_metadata.rs | 0 .../src}/entities/series_sharing_tags.rs | 0 .../codex-db/src}/entities/series_tags.rs | 0 .../codex-db/src}/entities/series_tracking.rs | 0 .../codex-db/src}/entities/settings.rs | 0 .../src}/entities/settings_history.rs | 0 .../codex-db/src}/entities/sharing_tags.rs | 0 .../codex-db/src}/entities/tags.rs | 0 .../codex-db/src}/entities/task_metrics.rs | 0 .../codex-db/src}/entities/tasks.rs | 0 .../src}/entities/user_plugin_data.rs | 0 .../codex-db/src}/entities/user_plugins.rs | 0 .../src}/entities/user_preferences.rs | 0 .../src}/entities/user_series_ratings.rs | 0 .../src}/entities/user_sharing_tags.rs | 0 .../codex-db/src}/entities/users.rs | 0 src/db/mod.rs => crates/codex-db/src/lib.rs | 4 + .../src}/repositories/alternate_title.rs | 11 +-- .../codex-db/src}/repositories/api_key.rs | 6 +- .../codex-db/src}/repositories/book.rs | 93 +++++++++---------- .../codex-db/src}/repositories/book_covers.rs | 10 +- .../src}/repositories/book_duplicates.rs | 2 +- .../src}/repositories/book_external_id.rs | 10 +- .../src}/repositories/book_external_links.rs | 12 +-- .../repositories/email_verification_token.rs | 8 +- .../src}/repositories/external_link.rs | 8 +- .../src}/repositories/external_rating.rs | 8 +- .../src}/repositories/filter_preset.rs | 10 +- .../codex-db/src}/repositories/genre.rs | 40 ++++---- .../codex-db/src}/repositories/library.rs | 12 +-- .../src}/repositories/library_jobs.rs | 10 +- .../codex-db/src}/repositories/metadata.rs | 12 +-- .../codex-db/src}/repositories/metrics.rs | 16 ++-- .../codex-db/src}/repositories/mod.rs | 0 .../src}/repositories/oidc_connection.rs | 8 +- .../codex-db/src}/repositories/page.rs | 22 ++--- .../src}/repositories/plugin_failures.rs | 8 +- .../codex-db/src}/repositories/plugins.rs | 6 +- .../src}/repositories/read_progress.rs | 8 +- .../src}/repositories/refresh_token.rs | 2 +- .../src}/repositories/release_ledger.rs | 22 ++--- .../src}/repositories/release_sources.rs | 6 +- .../codex-db/src}/repositories/series.rs | 42 ++++----- .../src}/repositories/series_aliases.rs | 8 +- .../src}/repositories/series_covers.rs | 8 +- .../src}/repositories/series_duplicates.rs | 4 +- .../src}/repositories/series_export.rs | 10 +- .../src}/repositories/series_external_id.rs | 8 +- .../src}/repositories/series_metadata.rs | 13 ++- .../src}/repositories/series_tracking.rs | 8 +- .../codex-db/src}/repositories/settings.rs | 4 +- .../codex-db/src}/repositories/sharing_tag.rs | 56 +++++------ .../codex-db/src}/repositories/tag.rs | 40 ++++---- .../codex-db/src}/repositories/task.rs | 2 +- .../src}/repositories/task_metrics.rs | 8 +- .../codex-db/src}/repositories/user.rs | 6 +- .../src}/repositories/user_plugin_data.rs | 12 +-- .../src}/repositories/user_plugins.rs | 12 +-- .../src}/repositories/user_preferences.rs | 8 +- .../src}/repositories/user_series_rating.rs | 12 +-- .../codex-db/src}/test_helpers.rs | 18 ++-- .../repo.rs => crates/codex-db/src/trace.rs | 20 ++-- src/api/docs.rs | 2 +- src/api/extractors/auth.rs | 2 +- src/api/routes/komga/dto/book.rs | 14 +-- src/api/routes/komga/handlers/books.rs | 12 +-- src/api/routes/komga/handlers/libraries.rs | 6 +- src/api/routes/komga/handlers/manifest.rs | 2 +- src/api/routes/komga/handlers/pages.rs | 2 +- .../routes/komga/handlers/read_progress.rs | 2 +- src/api/routes/komga/handlers/series.rs | 14 +-- src/api/routes/komga/handlers/stubs.rs | 2 +- src/api/routes/koreader/handlers/sync.rs | 4 +- src/api/routes/opds/handlers/catalog.rs | 8 +- src/api/routes/opds/handlers/pse.rs | 2 +- src/api/routes/opds/handlers/search.rs | 8 +- src/api/routes/opds2/handlers/catalog.rs | 8 +- src/api/routes/opds2/handlers/search.rs | 6 +- src/api/routes/v1/dto/book.rs | 24 ++--- src/api/routes/v1/dto/filter_preset.rs | 2 +- src/api/routes/v1/dto/plugins.rs | 6 +- src/api/routes/v1/dto/read_progress.rs | 4 +- src/api/routes/v1/dto/release.rs | 2 +- src/api/routes/v1/dto/series.rs | 4 +- src/api/routes/v1/dto/series_export.rs | 2 +- src/api/routes/v1/dto/sharing_tag.rs | 12 +-- src/api/routes/v1/dto/tracking.rs | 2 +- src/api/routes/v1/dto/user_plugins.rs | 4 +- src/api/routes/v1/dto/user_preferences.rs | 4 +- src/api/routes/v1/handlers/api_keys.rs | 4 +- src/api/routes/v1/handlers/auth.rs | 10 +- src/api/routes/v1/handlers/books.rs | 28 +++--- src/api/routes/v1/handlers/bulk.rs | 12 +-- src/api/routes/v1/handlers/bulk_metadata.rs | 10 +- src/api/routes/v1/handlers/cleanup.rs | 2 +- src/api/routes/v1/handlers/duplicates.rs | 12 +-- src/api/routes/v1/handlers/filter_presets.rs | 2 +- src/api/routes/v1/handlers/libraries.rs | 12 +-- src/api/routes/v1/handlers/library_jobs.rs | 10 +- src/api/routes/v1/handlers/metrics.rs | 2 +- src/api/routes/v1/handlers/oidc.rs | 12 +-- src/api/routes/v1/handlers/pages.rs | 4 +- src/api/routes/v1/handlers/pdf_cache.rs | 2 +- src/api/routes/v1/handlers/plugin_actions.rs | 14 +-- src/api/routes/v1/handlers/plugins.rs | 6 +- src/api/routes/v1/handlers/read_progress.rs | 2 +- src/api/routes/v1/handlers/recommendations.rs | 18 ++-- src/api/routes/v1/handlers/releases.rs | 6 +- src/api/routes/v1/handlers/scan.rs | 14 ++- src/api/routes/v1/handlers/series.rs | 26 +++--- src/api/routes/v1/handlers/series_exports.rs | 2 +- src/api/routes/v1/handlers/settings.rs | 2 +- src/api/routes/v1/handlers/setup.rs | 10 +- src/api/routes/v1/handlers/sharing_tags.rs | 2 +- src/api/routes/v1/handlers/task_metrics.rs | 2 +- src/api/routes/v1/handlers/task_queue.rs | 26 +++--- src/api/routes/v1/handlers/tracking.rs | 8 +- src/api/routes/v1/handlers/user_plugins.rs | 18 ++-- .../routes/v1/handlers/user_preferences.rs | 2 +- src/api/routes/v1/handlers/users.rs | 4 +- src/commands/common.rs | 4 +- src/commands/migrate.rs | 2 +- src/commands/seed.rs | 12 +-- src/commands/tasks.rs | 6 +- src/lib.rs | 6 +- src/main.rs | 1 - src/observability/inventory.rs | 4 +- src/observability/mod.rs | 2 - src/scanner/analyzer_queue.rs | 20 ++-- src/scanner/library_scanner.rs | 20 ++-- src/scanner/types.rs | 2 +- src/scheduler/mod.rs | 10 +- src/scheduler/release_sources.rs | 4 +- src/search/builder.rs | 14 +-- src/search/listener.rs | 14 +-- src/services/auth_tracking.rs | 8 +- src/services/book_export_collector.rs | 12 +-- src/services/cleanup_subscriber.rs | 4 +- src/services/content_filter.rs | 2 +- src/services/filter.rs | 78 ++++++++-------- src/services/library_jobs/mod.rs | 4 +- src/services/library_jobs/types.rs | 2 +- src/services/library_jobs/validation.rs | 4 +- src/services/metadata/apply.rs | 12 +-- src/services/metadata/book_apply.rs | 6 +- src/services/metadata/cover.rs | 4 +- .../metadata/preprocessing/context.rs | 4 +- src/services/metadata/refresh_planner.rs | 14 +-- src/services/plugin/library.rs | 10 +- src/services/plugin/manager.rs | 8 +- src/services/plugin/protocol.rs | 2 +- src/services/plugin/releases_handler.rs | 28 +++--- src/services/plugin/storage_handler.rs | 10 +- src/services/read_progress.rs | 10 +- src/services/refresh_token.rs | 10 +- src/services/release/auto_ignore.rs | 2 +- src/services/release/languages.rs | 2 +- src/services/release/matcher.rs | 4 +- src/services/release/seed.rs | 12 +-- src/services/release/tracking_toggle.rs | 8 +- src/services/release/upstream_gap.rs | 4 +- src/services/series_export_collector.rs | 10 +- src/services/settings.rs | 4 +- src/services/task_listener.rs | 2 +- src/services/task_metrics.rs | 4 +- src/services/thumbnail.rs | 4 +- src/services/user_plugin/token_refresh.rs | 4 +- src/tasks/handlers/analyze_book.rs | 4 +- src/tasks/handlers/analyze_series.rs | 4 +- src/tasks/handlers/backfill_tracking.rs | 10 +- src/tasks/handlers/bulk_track_for_releases.rs | 10 +- src/tasks/handlers/cleanup_book_files.rs | 2 +- src/tasks/handlers/cleanup_orphaned_files.rs | 4 +- src/tasks/handlers/cleanup_pdf_cache.rs | 2 +- src/tasks/handlers/cleanup_plugin_data.rs | 4 +- src/tasks/handlers/cleanup_refresh_tokens.rs | 10 +- src/tasks/handlers/cleanup_series_exports.rs | 8 +- src/tasks/handlers/cleanup_series_files.rs | 2 +- src/tasks/handlers/export_series.rs | 6 +- src/tasks/handlers/find_duplicates.rs | 6 +- .../handlers/generate_series_thumbnail.rs | 4 +- .../handlers/generate_series_thumbnails.rs | 4 +- src/tasks/handlers/generate_thumbnail.rs | 6 +- src/tasks/handlers/generate_thumbnails.rs | 4 +- src/tasks/handlers/mod.rs | 2 +- src/tasks/handlers/plugin_auto_match.rs | 12 +-- src/tasks/handlers/poll_release_source.rs | 36 +++---- src/tasks/handlers/purge_deleted.rs | 4 +- .../handlers/refresh_library_metadata.rs | 28 +++--- src/tasks/handlers/renumber_series.rs | 4 +- src/tasks/handlers/reprocess_series_titles.rs | 8 +- src/tasks/handlers/scan_library.rs | 8 +- .../user_plugin_recommendation_dismiss.rs | 2 +- .../handlers/user_plugin_recommendations.rs | 4 +- src/tasks/handlers/user_plugin_sync/mod.rs | 4 +- src/tasks/handlers/user_plugin_sync/pull.rs | 12 +-- src/tasks/handlers/user_plugin_sync/push.rs | 8 +- src/tasks/handlers/user_plugin_sync/tests.rs | 12 +-- src/tasks/worker.rs | 12 +-- 240 files changed, 977 insertions(+), 894 deletions(-) create mode 100644 crates/codex-db/Cargo.toml rename {src/db => crates/codex-db/src}/connection.rs (99%) rename {src/db => crates/codex-db/src}/entities/api_keys.rs (100%) rename {src/db => crates/codex-db/src}/entities/book_covers.rs (100%) rename {src/db => crates/codex-db/src}/entities/book_duplicates.rs (100%) rename {src/db => crates/codex-db/src}/entities/book_error.rs (100%) rename {src/db => crates/codex-db/src}/entities/book_external_ids.rs (100%) rename {src/db => crates/codex-db/src}/entities/book_external_links.rs (100%) rename {src/db => crates/codex-db/src}/entities/book_genres.rs (100%) rename {src/db => crates/codex-db/src}/entities/book_metadata.rs (100%) rename {src/db => crates/codex-db/src}/entities/book_tags.rs (100%) rename {src/db => crates/codex-db/src}/entities/books.rs (100%) rename {src/db => crates/codex-db/src}/entities/email_verification_tokens.rs (100%) rename {src/db => crates/codex-db/src}/entities/filter_presets.rs (100%) rename {src/db => crates/codex-db/src}/entities/genres.rs (100%) rename {src/db => crates/codex-db/src}/entities/libraries.rs (100%) rename {src/db => crates/codex-db/src}/entities/library_jobs.rs (100%) rename {src/db => crates/codex-db/src}/entities/metadata_sources.rs (100%) rename {src/db => crates/codex-db/src}/entities/mod.rs (100%) rename {src/db => crates/codex-db/src}/entities/oidc_connections.rs (100%) rename {src/db => crates/codex-db/src}/entities/pages.rs (100%) rename {src/db => crates/codex-db/src}/entities/plugin_failures.rs (100%) rename {src/db => crates/codex-db/src}/entities/plugins.rs (100%) rename {src/db => crates/codex-db/src}/entities/prelude.rs (100%) rename {src/db => crates/codex-db/src}/entities/read_progress.rs (100%) rename {src/db => crates/codex-db/src}/entities/refresh_tokens.rs (100%) rename {src/db => crates/codex-db/src}/entities/release_ledger.rs (100%) rename {src/db => crates/codex-db/src}/entities/release_sources.rs (98%) rename {src/db => crates/codex-db/src}/entities/series.rs (100%) rename {src/db => crates/codex-db/src}/entities/series_aliases.rs (100%) rename {src/db => crates/codex-db/src}/entities/series_alternate_titles.rs (100%) rename {src/db => crates/codex-db/src}/entities/series_covers.rs (100%) rename {src/db => crates/codex-db/src}/entities/series_duplicates.rs (100%) rename {src/db => crates/codex-db/src}/entities/series_exports.rs (100%) rename {src/db => crates/codex-db/src}/entities/series_external_ids.rs (100%) rename {src/db => crates/codex-db/src}/entities/series_external_links.rs (100%) rename {src/db => crates/codex-db/src}/entities/series_external_ratings.rs (100%) rename {src/db => crates/codex-db/src}/entities/series_genres.rs (100%) rename {src/db => crates/codex-db/src}/entities/series_metadata.rs (100%) rename {src/db => crates/codex-db/src}/entities/series_sharing_tags.rs (100%) rename {src/db => crates/codex-db/src}/entities/series_tags.rs (100%) rename {src/db => crates/codex-db/src}/entities/series_tracking.rs (100%) rename {src/db => crates/codex-db/src}/entities/settings.rs (100%) rename {src/db => crates/codex-db/src}/entities/settings_history.rs (100%) rename {src/db => crates/codex-db/src}/entities/sharing_tags.rs (100%) rename {src/db => crates/codex-db/src}/entities/tags.rs (100%) rename {src/db => crates/codex-db/src}/entities/task_metrics.rs (100%) rename {src/db => crates/codex-db/src}/entities/tasks.rs (100%) rename {src/db => crates/codex-db/src}/entities/user_plugin_data.rs (100%) rename {src/db => crates/codex-db/src}/entities/user_plugins.rs (100%) rename {src/db => crates/codex-db/src}/entities/user_preferences.rs (100%) rename {src/db => crates/codex-db/src}/entities/user_series_ratings.rs (100%) rename {src/db => crates/codex-db/src}/entities/user_sharing_tags.rs (100%) rename {src/db => crates/codex-db/src}/entities/users.rs (100%) rename src/db/mod.rs => crates/codex-db/src/lib.rs (62%) rename {src/db => crates/codex-db/src}/repositories/alternate_title.rs (98%) rename {src/db => crates/codex-db/src}/repositories/api_key.rs (97%) rename {src/db => crates/codex-db/src}/repositories/book.rs (97%) rename {src/db => crates/codex-db/src}/repositories/book_covers.rs (98%) rename {src/db => crates/codex-db/src}/repositories/book_duplicates.rs (99%) rename {src/db => crates/codex-db/src}/repositories/book_external_id.rs (98%) rename {src/db => crates/codex-db/src}/repositories/book_external_links.rs (98%) rename {src/db => crates/codex-db/src}/repositories/email_verification_token.rs (97%) rename {src/db => crates/codex-db/src}/repositories/external_link.rs (98%) rename {src/db => crates/codex-db/src}/repositories/external_rating.rs (98%) rename {src/db => crates/codex-db/src}/repositories/filter_preset.rs (98%) rename {src/db => crates/codex-db/src}/repositories/genre.rs (96%) rename {src/db => crates/codex-db/src}/repositories/library.rs (98%) rename {src/db => crates/codex-db/src}/repositories/library_jobs.rs (98%) rename {src/db => crates/codex-db/src}/repositories/metadata.rs (98%) rename {src/db => crates/codex-db/src}/repositories/metrics.rs (97%) rename {src/db => crates/codex-db/src}/repositories/mod.rs (100%) rename {src/db => crates/codex-db/src}/repositories/oidc_connection.rs (98%) rename {src/db => crates/codex-db/src}/repositories/page.rs (96%) rename {src/db => crates/codex-db/src}/repositories/plugin_failures.rs (99%) rename {src/db => crates/codex-db/src}/repositories/plugins.rs (99%) rename {src/db => crates/codex-db/src}/repositories/read_progress.rs (99%) rename {src/db => crates/codex-db/src}/repositories/refresh_token.rs (98%) rename {src/db => crates/codex-db/src}/repositories/release_ledger.rs (98%) rename {src/db => crates/codex-db/src}/repositories/release_sources.rs (99%) rename {src/db => crates/codex-db/src}/repositories/series.rs (99%) rename {src/db => crates/codex-db/src}/repositories/series_aliases.rs (98%) rename {src/db => crates/codex-db/src}/repositories/series_covers.rs (98%) rename {src/db => crates/codex-db/src}/repositories/series_duplicates.rs (99%) rename {src/db => crates/codex-db/src}/repositories/series_export.rs (98%) rename {src/db => crates/codex-db/src}/repositories/series_external_id.rs (99%) rename {src/db => crates/codex-db/src}/repositories/series_metadata.rs (98%) rename {src/db => crates/codex-db/src}/repositories/series_tracking.rs (98%) rename {src/db => crates/codex-db/src}/repositories/settings.rs (99%) rename {src/db => crates/codex-db/src}/repositories/sharing_tag.rs (94%) rename {src/db => crates/codex-db/src}/repositories/tag.rs (96%) rename {src/db => crates/codex-db/src}/repositories/task.rs (99%) rename {src/db => crates/codex-db/src}/repositories/task_metrics.rs (99%) rename {src/db => crates/codex-db/src}/repositories/user.rs (98%) rename {src/db => crates/codex-db/src}/repositories/user_plugin_data.rs (98%) rename {src/db => crates/codex-db/src}/repositories/user_plugins.rs (98%) rename {src/db => crates/codex-db/src}/repositories/user_preferences.rs (99%) rename {src/db => crates/codex-db/src}/repositories/user_series_rating.rs (98%) rename {src/db => crates/codex-db/src}/test_helpers.rs (79%) rename src/observability/repo.rs => crates/codex-db/src/trace.rs (91%) diff --git a/Cargo.lock b/Cargo.lock index 2604dce5..c5abb15d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -822,6 +822,7 @@ dependencies = [ "chrono-tz", "clap", "codex-config", + "codex-db", "codex-events", "codex-models", "codex-parsers", @@ -862,7 +863,6 @@ dependencies = [ "resvg", "rust-embed", "sea-orm", - "sea-orm-migration", "serde", "serde_json", "serde_yaml", @@ -904,6 +904,32 @@ dependencies = [ "tempfile", ] +[[package]] +name = "codex-db" +version = "0.0.0" +dependencies = [ + "anyhow", + "chrono", + "codex-config", + "codex-events", + "codex-models", + "codex-utils", + "log", + "migration", + "rand 0.10.0", + "sea-orm", + "sea-orm-migration", + "serde", + "serde_json", + "serial_test", + "tempfile", + "tokio", + "tracing", + "tracing-subscriber", + "utoipa", + "uuid", +] + [[package]] name = "codex-events" version = "0.0.0" diff --git a/Cargo.toml b/Cargo.toml index edbd4283..b192489a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -36,6 +36,7 @@ members = [ "crates/codex-models", "crates/codex-utils", "crates/codex-parsers", + "crates/codex-db", ] # Shared dependencies inherited by workspace members. Only deps that are @@ -59,6 +60,7 @@ uuid = { version = "1.0", features = ["v4", "serde"] } # Workspace-internal crates. Declaring them here keeps cross-crate path edges # in one place so members reference each other via `{ workspace = true }`. codex-config = { path = "crates/codex-config" } +codex-db = { path = "crates/codex-db" } codex-events = { path = "crates/codex-events" } codex-models = { path = "crates/codex-models" } codex-parsers = { path = "crates/codex-parsers", default-features = false } @@ -136,13 +138,11 @@ sea-orm = { version = "1.1", features = [ "with-chrono", "with-uuid", ] } -sea-orm-migration = { version = "1.1", features = [ - "runtime-tokio-rustls", - "sqlx-postgres", - "sqlx-sqlite", -] } +# `sea-orm-migration` lives in codex-db; root reaches the `migration` crate +# directly for the oidc handler's one-off Migrator::up call. migration = { path = "migration" } codex-config = { workspace = true } +codex-db = { workspace = true } codex-events = { workspace = true } codex-models = { workspace = true } codex-parsers = { workspace = true } @@ -243,6 +243,10 @@ http-body-util = "0.1" hyper = { version = "1.0", features = ["full"] } serial_test = { workspace = true } tracing-test = "0.2" +# Enables codex_db::test_helpers (gated behind the `test-utils` feature) so +# the root crate's `#[cfg(test)]` blocks and the `tests/` integration suite +# can mint SQLite test databases. +codex-db = { workspace = true, features = ["test-utils"] } # Used by tests/common/files.rs to mint PDF fixtures. The runtime PDF # rendering path lives in codex-parsers; tests reach for lopdf directly to # craft byte-level inputs. diff --git a/Makefile b/Makefile index 60b79541..3db1144a 100644 --- a/Makefile +++ b/Makefile @@ -90,7 +90,7 @@ dev-check: ## Check development tool installation # ============================================================================= test: ## Run backend tests (SQLite) - cargo test + cargo test --workspace test-frontend: ## Run frontend tests cd web && npm run test:run @@ -111,7 +111,7 @@ test-postgres-run: ## Run PostgreSQL tests (assumes DB is running) @until docker exec codex-postgres-test pg_isready -U codex_test > /dev/null 2>&1; do sleep 1; done @echo "$(GREEN)PostgreSQL is ready!$(NC)" POSTGRES_HOST=localhost POSTGRES_PORT=5433 POSTGRES_USER=codex_test POSTGRES_PASSWORD=codex_test POSTGRES_DB=codex_test \ - cargo test --features rar -- --include-ignored + cargo test --workspace --features rar -- --include-ignored test-up: ## Start test database docker compose --profile test up -d postgres-test @@ -126,7 +126,7 @@ test-clean: ## Stop test database and remove volumes # Install: cargo install cargo-nextest --locked test-fast: ## Run backend tests with nextest (faster, parallel) - cargo nextest run + cargo nextest run --workspace test-fast-all: ## Run all tests with nextest (faster, parallel) @echo "$(YELLOW)Running frontend tests...$(NC)" @@ -144,7 +144,7 @@ test-fast-postgres-run: ## Run PostgreSQL tests with nextest (assumes DB running @until docker exec codex-postgres-test pg_isready -U codex_test > /dev/null 2>&1; do sleep 1; done @echo "$(GREEN)PostgreSQL is ready!$(NC)" POSTGRES_HOST=localhost POSTGRES_PORT=5433 POSTGRES_USER=codex_test POSTGRES_PASSWORD=codex_test POSTGRES_DB=codex_test \ - cargo nextest run --features rar --run-ignored all + cargo nextest run --workspace --features rar --run-ignored all # ============================================================================= # Documentation (Docusaurus) diff --git a/crates/codex-db/Cargo.toml b/crates/codex-db/Cargo.toml new file mode 100644 index 00000000..a185d4e8 --- /dev/null +++ b/crates/codex-db/Cargo.toml @@ -0,0 +1,61 @@ +[package] +name = "codex-db" +version = "0.0.0" +edition = "2024" +publish = false + +[lib] +name = "codex_db" +path = "src/lib.rs" + +[features] +# Exposes `codex_db::test_helpers` to downstream crates (root binary tests, +# integration tests). Off by default so release builds don't pull in +# tempfile / SQLite test plumbing. +test-utils = ["dep:tempfile"] + +[dependencies] +anyhow = { workspace = true } +chrono = { workspace = true } +serde = { workspace = true } +tokio = { workspace = true } +tracing = { workspace = true } +utoipa = { workspace = true } +uuid = { workspace = true } + +codex-config = { workspace = true } +codex-events = { workspace = true } +codex-models = { workspace = true } +codex-utils = { workspace = true } + +# SeaORM + migrations. Feature set must match the root crate's historical +# config so the dual SQLite/Postgres backends stay supported. +sea-orm = { version = "1.1", features = [ + "sqlx-postgres", + "sqlx-sqlite", + "runtime-tokio-rustls", + "macros", + "with-chrono", + "with-uuid", +] } +sea-orm-migration = { version = "1.1", features = [ + "runtime-tokio-rustls", + "sqlx-postgres", + "sqlx-sqlite", +] } +migration = { path = "../../migration" } + +# Repository-level helpers. +serde_json = "1.0" +rand = "0.10" +# Used by `connection::Database` to set sqlx logging level on connect. +log = "0.4" +# Pulled in optionally for `test-utils` so test_helpers can mint temp +# SQLite files in downstream test runs. +tempfile = { workspace = true, optional = true } + +[dev-dependencies] +tempfile = { workspace = true } +serial_test = { workspace = true } +# Capturing tracing layer for the trace.rs span-emission test. +tracing-subscriber = { version = "0.3", features = ["env-filter"] } diff --git a/src/db/connection.rs b/crates/codex-db/src/connection.rs similarity index 99% rename from src/db/connection.rs rename to crates/codex-db/src/connection.rs index 372c8237..19ec22a2 100644 --- a/src/db/connection.rs +++ b/crates/codex-db/src/connection.rs @@ -13,7 +13,7 @@ use tracing::info; use uuid::Uuid; use super::ScanningStrategy; -use crate::db::entities; +use crate::entities; use codex_config::{DatabaseConfig, DatabaseType}; use super::repositories::{ diff --git a/src/db/entities/api_keys.rs b/crates/codex-db/src/entities/api_keys.rs similarity index 100% rename from src/db/entities/api_keys.rs rename to crates/codex-db/src/entities/api_keys.rs diff --git a/src/db/entities/book_covers.rs b/crates/codex-db/src/entities/book_covers.rs similarity index 100% rename from src/db/entities/book_covers.rs rename to crates/codex-db/src/entities/book_covers.rs diff --git a/src/db/entities/book_duplicates.rs b/crates/codex-db/src/entities/book_duplicates.rs similarity index 100% rename from src/db/entities/book_duplicates.rs rename to crates/codex-db/src/entities/book_duplicates.rs diff --git a/src/db/entities/book_error.rs b/crates/codex-db/src/entities/book_error.rs similarity index 100% rename from src/db/entities/book_error.rs rename to crates/codex-db/src/entities/book_error.rs diff --git a/src/db/entities/book_external_ids.rs b/crates/codex-db/src/entities/book_external_ids.rs similarity index 100% rename from src/db/entities/book_external_ids.rs rename to crates/codex-db/src/entities/book_external_ids.rs diff --git a/src/db/entities/book_external_links.rs b/crates/codex-db/src/entities/book_external_links.rs similarity index 100% rename from src/db/entities/book_external_links.rs rename to crates/codex-db/src/entities/book_external_links.rs diff --git a/src/db/entities/book_genres.rs b/crates/codex-db/src/entities/book_genres.rs similarity index 100% rename from src/db/entities/book_genres.rs rename to crates/codex-db/src/entities/book_genres.rs diff --git a/src/db/entities/book_metadata.rs b/crates/codex-db/src/entities/book_metadata.rs similarity index 100% rename from src/db/entities/book_metadata.rs rename to crates/codex-db/src/entities/book_metadata.rs diff --git a/src/db/entities/book_tags.rs b/crates/codex-db/src/entities/book_tags.rs similarity index 100% rename from src/db/entities/book_tags.rs rename to crates/codex-db/src/entities/book_tags.rs diff --git a/src/db/entities/books.rs b/crates/codex-db/src/entities/books.rs similarity index 100% rename from src/db/entities/books.rs rename to crates/codex-db/src/entities/books.rs diff --git a/src/db/entities/email_verification_tokens.rs b/crates/codex-db/src/entities/email_verification_tokens.rs similarity index 100% rename from src/db/entities/email_verification_tokens.rs rename to crates/codex-db/src/entities/email_verification_tokens.rs diff --git a/src/db/entities/filter_presets.rs b/crates/codex-db/src/entities/filter_presets.rs similarity index 100% rename from src/db/entities/filter_presets.rs rename to crates/codex-db/src/entities/filter_presets.rs diff --git a/src/db/entities/genres.rs b/crates/codex-db/src/entities/genres.rs similarity index 100% rename from src/db/entities/genres.rs rename to crates/codex-db/src/entities/genres.rs diff --git a/src/db/entities/libraries.rs b/crates/codex-db/src/entities/libraries.rs similarity index 100% rename from src/db/entities/libraries.rs rename to crates/codex-db/src/entities/libraries.rs diff --git a/src/db/entities/library_jobs.rs b/crates/codex-db/src/entities/library_jobs.rs similarity index 100% rename from src/db/entities/library_jobs.rs rename to crates/codex-db/src/entities/library_jobs.rs diff --git a/src/db/entities/metadata_sources.rs b/crates/codex-db/src/entities/metadata_sources.rs similarity index 100% rename from src/db/entities/metadata_sources.rs rename to crates/codex-db/src/entities/metadata_sources.rs diff --git a/src/db/entities/mod.rs b/crates/codex-db/src/entities/mod.rs similarity index 100% rename from src/db/entities/mod.rs rename to crates/codex-db/src/entities/mod.rs diff --git a/src/db/entities/oidc_connections.rs b/crates/codex-db/src/entities/oidc_connections.rs similarity index 100% rename from src/db/entities/oidc_connections.rs rename to crates/codex-db/src/entities/oidc_connections.rs diff --git a/src/db/entities/pages.rs b/crates/codex-db/src/entities/pages.rs similarity index 100% rename from src/db/entities/pages.rs rename to crates/codex-db/src/entities/pages.rs diff --git a/src/db/entities/plugin_failures.rs b/crates/codex-db/src/entities/plugin_failures.rs similarity index 100% rename from src/db/entities/plugin_failures.rs rename to crates/codex-db/src/entities/plugin_failures.rs diff --git a/src/db/entities/plugins.rs b/crates/codex-db/src/entities/plugins.rs similarity index 100% rename from src/db/entities/plugins.rs rename to crates/codex-db/src/entities/plugins.rs diff --git a/src/db/entities/prelude.rs b/crates/codex-db/src/entities/prelude.rs similarity index 100% rename from src/db/entities/prelude.rs rename to crates/codex-db/src/entities/prelude.rs diff --git a/src/db/entities/read_progress.rs b/crates/codex-db/src/entities/read_progress.rs similarity index 100% rename from src/db/entities/read_progress.rs rename to crates/codex-db/src/entities/read_progress.rs diff --git a/src/db/entities/refresh_tokens.rs b/crates/codex-db/src/entities/refresh_tokens.rs similarity index 100% rename from src/db/entities/refresh_tokens.rs rename to crates/codex-db/src/entities/refresh_tokens.rs diff --git a/src/db/entities/release_ledger.rs b/crates/codex-db/src/entities/release_ledger.rs similarity index 100% rename from src/db/entities/release_ledger.rs rename to crates/codex-db/src/entities/release_ledger.rs diff --git a/src/db/entities/release_sources.rs b/crates/codex-db/src/entities/release_sources.rs similarity index 98% rename from src/db/entities/release_sources.rs rename to crates/codex-db/src/entities/release_sources.rs index 20e968c0..6261f18e 100644 --- a/src/db/entities/release_sources.rs +++ b/crates/codex-db/src/entities/release_sources.rs @@ -23,7 +23,7 @@ pub struct Model { /// self-reference over RPC. It is *not* the canonical lifecycle anchor; /// see [`Self::plugin_uuid`] for the FK that drives cascade-on-delete. pub plugin_id: String, - /// Foreign key to [`crate::db::entities::plugins::Model::id`] with + /// Foreign key to [`crate::entities::plugins::Model::id`] with /// `ON DELETE CASCADE`. Populated by the repository on insert via a /// `plugins.find_by_name(plugin_id)` lookup. `None` for synthetic /// `plugin_id = "core"` rows that don't correspond to a real plugin. diff --git a/src/db/entities/series.rs b/crates/codex-db/src/entities/series.rs similarity index 100% rename from src/db/entities/series.rs rename to crates/codex-db/src/entities/series.rs diff --git a/src/db/entities/series_aliases.rs b/crates/codex-db/src/entities/series_aliases.rs similarity index 100% rename from src/db/entities/series_aliases.rs rename to crates/codex-db/src/entities/series_aliases.rs diff --git a/src/db/entities/series_alternate_titles.rs b/crates/codex-db/src/entities/series_alternate_titles.rs similarity index 100% rename from src/db/entities/series_alternate_titles.rs rename to crates/codex-db/src/entities/series_alternate_titles.rs diff --git a/src/db/entities/series_covers.rs b/crates/codex-db/src/entities/series_covers.rs similarity index 100% rename from src/db/entities/series_covers.rs rename to crates/codex-db/src/entities/series_covers.rs diff --git a/src/db/entities/series_duplicates.rs b/crates/codex-db/src/entities/series_duplicates.rs similarity index 100% rename from src/db/entities/series_duplicates.rs rename to crates/codex-db/src/entities/series_duplicates.rs diff --git a/src/db/entities/series_exports.rs b/crates/codex-db/src/entities/series_exports.rs similarity index 100% rename from src/db/entities/series_exports.rs rename to crates/codex-db/src/entities/series_exports.rs diff --git a/src/db/entities/series_external_ids.rs b/crates/codex-db/src/entities/series_external_ids.rs similarity index 100% rename from src/db/entities/series_external_ids.rs rename to crates/codex-db/src/entities/series_external_ids.rs diff --git a/src/db/entities/series_external_links.rs b/crates/codex-db/src/entities/series_external_links.rs similarity index 100% rename from src/db/entities/series_external_links.rs rename to crates/codex-db/src/entities/series_external_links.rs diff --git a/src/db/entities/series_external_ratings.rs b/crates/codex-db/src/entities/series_external_ratings.rs similarity index 100% rename from src/db/entities/series_external_ratings.rs rename to crates/codex-db/src/entities/series_external_ratings.rs diff --git a/src/db/entities/series_genres.rs b/crates/codex-db/src/entities/series_genres.rs similarity index 100% rename from src/db/entities/series_genres.rs rename to crates/codex-db/src/entities/series_genres.rs diff --git a/src/db/entities/series_metadata.rs b/crates/codex-db/src/entities/series_metadata.rs similarity index 100% rename from src/db/entities/series_metadata.rs rename to crates/codex-db/src/entities/series_metadata.rs diff --git a/src/db/entities/series_sharing_tags.rs b/crates/codex-db/src/entities/series_sharing_tags.rs similarity index 100% rename from src/db/entities/series_sharing_tags.rs rename to crates/codex-db/src/entities/series_sharing_tags.rs diff --git a/src/db/entities/series_tags.rs b/crates/codex-db/src/entities/series_tags.rs similarity index 100% rename from src/db/entities/series_tags.rs rename to crates/codex-db/src/entities/series_tags.rs diff --git a/src/db/entities/series_tracking.rs b/crates/codex-db/src/entities/series_tracking.rs similarity index 100% rename from src/db/entities/series_tracking.rs rename to crates/codex-db/src/entities/series_tracking.rs diff --git a/src/db/entities/settings.rs b/crates/codex-db/src/entities/settings.rs similarity index 100% rename from src/db/entities/settings.rs rename to crates/codex-db/src/entities/settings.rs diff --git a/src/db/entities/settings_history.rs b/crates/codex-db/src/entities/settings_history.rs similarity index 100% rename from src/db/entities/settings_history.rs rename to crates/codex-db/src/entities/settings_history.rs diff --git a/src/db/entities/sharing_tags.rs b/crates/codex-db/src/entities/sharing_tags.rs similarity index 100% rename from src/db/entities/sharing_tags.rs rename to crates/codex-db/src/entities/sharing_tags.rs diff --git a/src/db/entities/tags.rs b/crates/codex-db/src/entities/tags.rs similarity index 100% rename from src/db/entities/tags.rs rename to crates/codex-db/src/entities/tags.rs diff --git a/src/db/entities/task_metrics.rs b/crates/codex-db/src/entities/task_metrics.rs similarity index 100% rename from src/db/entities/task_metrics.rs rename to crates/codex-db/src/entities/task_metrics.rs diff --git a/src/db/entities/tasks.rs b/crates/codex-db/src/entities/tasks.rs similarity index 100% rename from src/db/entities/tasks.rs rename to crates/codex-db/src/entities/tasks.rs diff --git a/src/db/entities/user_plugin_data.rs b/crates/codex-db/src/entities/user_plugin_data.rs similarity index 100% rename from src/db/entities/user_plugin_data.rs rename to crates/codex-db/src/entities/user_plugin_data.rs diff --git a/src/db/entities/user_plugins.rs b/crates/codex-db/src/entities/user_plugins.rs similarity index 100% rename from src/db/entities/user_plugins.rs rename to crates/codex-db/src/entities/user_plugins.rs diff --git a/src/db/entities/user_preferences.rs b/crates/codex-db/src/entities/user_preferences.rs similarity index 100% rename from src/db/entities/user_preferences.rs rename to crates/codex-db/src/entities/user_preferences.rs diff --git a/src/db/entities/user_series_ratings.rs b/crates/codex-db/src/entities/user_series_ratings.rs similarity index 100% rename from src/db/entities/user_series_ratings.rs rename to crates/codex-db/src/entities/user_series_ratings.rs diff --git a/src/db/entities/user_sharing_tags.rs b/crates/codex-db/src/entities/user_sharing_tags.rs similarity index 100% rename from src/db/entities/user_sharing_tags.rs rename to crates/codex-db/src/entities/user_sharing_tags.rs diff --git a/src/db/entities/users.rs b/crates/codex-db/src/entities/users.rs similarity index 100% rename from src/db/entities/users.rs rename to crates/codex-db/src/entities/users.rs diff --git a/src/db/mod.rs b/crates/codex-db/src/lib.rs similarity index 62% rename from src/db/mod.rs rename to crates/codex-db/src/lib.rs index eaff8ea1..8e5dc51e 100644 --- a/src/db/mod.rs +++ b/crates/codex-db/src/lib.rs @@ -1,7 +1,11 @@ pub mod connection; pub mod entities; pub mod repositories; +pub mod trace; +// Available to codex-db's own `#[cfg(test)]` modules and to downstream crates +// that opt into the `test-utils` feature (e.g. the root binary's dev-deps). +#[cfg(any(test, feature = "test-utils"))] pub mod test_helpers; // Re-export commonly used types diff --git a/src/db/repositories/alternate_title.rs b/crates/codex-db/src/repositories/alternate_title.rs similarity index 98% rename from src/db/repositories/alternate_title.rs rename to crates/codex-db/src/repositories/alternate_title.rs index cbcbe263..170c0999 100644 --- a/src/db/repositories/alternate_title.rs +++ b/crates/codex-db/src/repositories/alternate_title.rs @@ -10,7 +10,7 @@ use sea_orm::{ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, Qu use std::sync::Arc; use uuid::Uuid; -use crate::db::entities::series_alternate_titles::{ +use crate::entities::series_alternate_titles::{ self, Entity as AlternateTitles, Model as AlternateTitle, }; use codex_events::{EntityChangeEvent, EntityEvent, EventBroadcaster}; @@ -198,8 +198,7 @@ async fn emit_metadata_updated( let Some(broadcaster) = broadcaster else { return; }; - let library_id = match crate::db::repositories::SeriesRepository::get_by_id(db, series_id).await - { + let library_id = match crate::repositories::SeriesRepository::get_by_id(db, series_id).await { Ok(Some(series)) => series.library_id, Ok(None) => { tracing::debug!( @@ -232,9 +231,9 @@ async fn emit_metadata_updated( #[cfg(test)] mod tests { use super::*; - use crate::db::ScanningStrategy; - use crate::db::repositories::{LibraryRepository, SeriesRepository}; - use crate::db::test_helpers::create_test_db; + use crate::ScanningStrategy; + use crate::repositories::{LibraryRepository, SeriesRepository}; + use crate::test_helpers::create_test_db; #[tokio::test] async fn test_create_and_get_alternate_title() { diff --git a/src/db/repositories/api_key.rs b/crates/codex-db/src/repositories/api_key.rs similarity index 97% rename from src/db/repositories/api_key.rs rename to crates/codex-db/src/repositories/api_key.rs index 4a5bef7c..ba37058d 100644 --- a/src/db/repositories/api_key.rs +++ b/crates/codex-db/src/repositories/api_key.rs @@ -4,7 +4,7 @@ #![allow(dead_code)] -use crate::db::entities::{api_keys, api_keys::Entity as ApiKey}; +use crate::entities::{api_keys, api_keys::Entity as ApiKey}; use anyhow::Result; use chrono::Utc; use sea_orm::*; @@ -128,8 +128,8 @@ impl ApiKeyRepository { #[cfg(test)] mod tests { use super::*; - use crate::db::repositories::user::UserRepository; - use crate::db::{entities::users, test_helpers::setup_test_db}; + use crate::repositories::user::UserRepository; + use crate::{entities::users, test_helpers::setup_test_db}; async fn create_test_user(db: &DatabaseConnection) -> users::Model { let user = users::Model { diff --git a/src/db/repositories/book.rs b/crates/codex-db/src/repositories/book.rs similarity index 97% rename from src/db/repositories/book.rs rename to crates/codex-db/src/repositories/book.rs index 6ee8a351..e1a49923 100644 --- a/src/db/repositories/book.rs +++ b/crates/codex-db/src/repositories/book.rs @@ -14,9 +14,9 @@ use sea_orm::{ use std::sync::Arc; use uuid::Uuid; -use crate::db::entities::{books, prelude::*}; -use crate::db::repositories::SeriesRepository; -use crate::observability::repo::db_system_str; +use crate::entities::{books, prelude::*}; +use crate::repositories::SeriesRepository; +use crate::trace::db_system_str; use codex_events::{EntityChangeEvent, EntityEvent, EventBroadcaster}; use codex_utils::normalize_for_search; @@ -151,7 +151,7 @@ impl BookRepository { db: &DatabaseConnection, options: BookQueryOptions<'_>, ) -> Result<(Vec<books::Model>, u64)> { - use crate::db::entities::{book_metadata, read_progress, series, series_metadata}; + use crate::entities::{book_metadata, read_progress, series, series_metadata}; let mut query = Books::find(); // Track whether book_metadata has been joined to avoid ambiguous column references @@ -433,8 +433,7 @@ impl BookRepository { if let Some(broadcaster) = event_broadcaster { // Get library_id by finding the series if let Ok(Some(series)) = - crate::db::repositories::SeriesRepository::get_by_id(db, created_book.series_id) - .await + crate::repositories::SeriesRepository::get_by_id(db, created_book.series_id).await { let event = EntityChangeEvent::new( EntityEvent::BookCreated { @@ -614,7 +613,7 @@ impl BookRepository { series_id: Uuid, include_deleted: bool, ) -> Result<Vec<books::Model>> { - use crate::db::entities::book_metadata; + use crate::entities::book_metadata; use sea_orm::JoinType; let mut query = Books::find().filter(books::Column::SeriesId.eq(series_id)); @@ -701,7 +700,7 @@ impl BookRepository { db: &DatabaseConnection, series_id: Uuid, ) -> Result<Option<books::Model>> { - use crate::db::entities::book_metadata; + use crate::entities::book_metadata; use sea_orm::JoinType; Books::find() @@ -748,7 +747,7 @@ impl BookRepository { .context("Book not found")?; // Get all non-deleted books in the series, ordered by metadata fields - use crate::db::entities::book_metadata; + use crate::entities::book_metadata; use sea_orm::JoinType; let all_books = Books::find() @@ -802,7 +801,7 @@ impl BookRepository { .context("Failed to count books")?; // Get paginated results - order by metadata fields - use crate::db::entities::book_metadata; + use crate::entities::book_metadata; use sea_orm::JoinType; let books = query @@ -881,7 +880,7 @@ impl BookRepository { query = query.filter(books::Column::Deleted.eq(false)); } - use crate::db::entities::book_metadata; + use crate::entities::book_metadata; use sea_orm::JoinType; let books = query @@ -918,7 +917,7 @@ impl BookRepository { offset: u64, limit: u64, ) -> Result<(Vec<books::Model>, u64)> { - use crate::db::entities::{book_metadata, read_progress, series, series_metadata}; + use crate::entities::{book_metadata, read_progress, series, series_metadata}; use codex_models::sort::{BookSortField, SortDirection}; use sea_orm::{Condition, JoinType}; @@ -1105,7 +1104,7 @@ impl BookRepository { .context("Failed to count books in library")?; // Get paginated results - order by metadata fields - use crate::db::entities::book_metadata; + use crate::entities::book_metadata; use sea_orm::JoinType; let books = query @@ -1134,7 +1133,7 @@ impl BookRepository { page: u64, page_size: u64, ) -> Result<(Vec<books::Model>, u64)> { - use crate::db::entities::{series, series_metadata}; + use crate::entities::{series, series_metadata}; use sea_orm::{JoinType, Order}; // Build query filtering directly by library_id (now on books table) @@ -1157,7 +1156,7 @@ impl BookRepository { // Get paginated results with series sorting // JOIN with series, series_metadata and book_metadata for sorting - use crate::db::entities::book_metadata; + use crate::entities::book_metadata; let books = query .join(JoinType::LeftJoin, books::Relation::Series.def()) @@ -1198,7 +1197,7 @@ impl BookRepository { page: u64, page_size: u64, ) -> Result<(Vec<books::Model>, u64)> { - use crate::db::entities::{book_metadata, series, series_metadata}; + use crate::entities::{book_metadata, series, series_metadata}; use codex_models::sort::{BookSortField, SortDirection}; use sea_orm::JoinType; @@ -1330,7 +1329,7 @@ impl BookRepository { page: u64, page_size: u64, ) -> Result<(Vec<books::Model>, u64)> { - use crate::db::entities::series; + use crate::entities::series; use sea_orm::JoinType; let mut query = Books::find(); @@ -1374,7 +1373,7 @@ impl BookRepository { library_id: Option<Uuid>, limit: u64, ) -> Result<Vec<books::Model>> { - use crate::db::entities::{read_progress, series}; + use crate::entities::{read_progress, series}; use sea_orm::JoinType; let mut query = Books::find() @@ -1406,7 +1405,7 @@ impl BookRepository { page: u64, page_size: u64, ) -> Result<(Vec<books::Model>, u64)> { - use crate::db::entities::{read_progress, series}; + use crate::entities::{read_progress, series}; use sea_orm::JoinType; let mut query = Books::find() @@ -1472,7 +1471,7 @@ impl BookRepository { page: u64, page_size: u64, ) -> Result<(Vec<books::Model>, u64)> { - use crate::db::entities::{read_progress, series}; + use crate::entities::{read_progress, series}; use sea_orm::JoinType; // Get all book IDs that have progress for this user @@ -1530,7 +1529,7 @@ impl BookRepository { page: u64, page_size: u64, ) -> Result<(Vec<books::Model>, u64)> { - use crate::db::entities::{read_progress, series}; + use crate::entities::{read_progress, series}; use sea_orm::JoinType; // Step 1: Get series where user has completed at least one book, @@ -1614,7 +1613,7 @@ impl BookRepository { } // Order by series, then by book number/title/filename (from metadata) - use crate::db::entities::book_metadata; + use crate::entities::book_metadata; let all_unread_books = unread_query .join(JoinType::LeftJoin, books::Relation::BookMetadata.def()) @@ -1683,7 +1682,7 @@ impl BookRepository { include_deleted: bool, pagination: Option<(u64, u64)>, ) -> Result<(Vec<books::Model>, u64)> { - use crate::db::entities::book_metadata; + use crate::entities::book_metadata; use sea_orm::JoinType; // Short-circuit if candidate_ids is explicitly empty @@ -1848,7 +1847,7 @@ impl BookRepository { // Clean up duplicates when soft-deleting (removed books shouldn't appear in duplicates) if deleted { - use crate::db::repositories::BookDuplicatesRepository; + use crate::repositories::BookDuplicatesRepository; BookDuplicatesRepository::cleanup_for_book(db, book_id).await?; } @@ -1890,7 +1889,7 @@ impl BookRepository { .context("Failed to delete book")?; // Clean up duplicates after deleting a book - use crate::db::repositories::BookDuplicatesRepository; + use crate::repositories::BookDuplicatesRepository; BookDuplicatesRepository::cleanup_for_book(db, id).await?; Ok(()) @@ -1900,7 +1899,7 @@ impl BookRepository { pub async fn count_by_library(db: &DatabaseConnection, library_id: Uuid) -> Result<i64> { // Get all series in the library let series_list = - crate::db::repositories::SeriesRepository::list_by_library(db, library_id).await?; + crate::repositories::SeriesRepository::list_by_library(db, library_id).await?; let series_ids: Vec<Uuid> = series_list.iter().map(|s| s.id).collect(); if series_ids.is_empty() { @@ -1928,7 +1927,7 @@ impl BookRepository { ) -> Result<u64> { // Get all series in the library let series_list = - crate::db::repositories::SeriesRepository::list_by_library(db, library_id).await?; + crate::repositories::SeriesRepository::list_by_library(db, library_id).await?; let series_ids: Vec<Uuid> = series_list.iter().map(|s| s.id).collect(); if series_ids.is_empty() { @@ -1979,7 +1978,7 @@ impl BookRepository { } // Check if we should purge empty series - let purge_empty_series = crate::db::repositories::SettingsRepository::get_value::<bool>( + let purge_empty_series = crate::repositories::SettingsRepository::get_value::<bool>( db, "purge.purge_empty_series", ) @@ -1990,7 +1989,7 @@ impl BookRepository { if purge_empty_series { // Purge empty series after deleting books let _series_deleted = - crate::db::repositories::SeriesRepository::purge_empty_series_in_library( + crate::repositories::SeriesRepository::purge_empty_series_in_library( db, library_id, event_broadcaster, @@ -2009,7 +2008,7 @@ impl BookRepository { event_broadcaster: Option<&Arc<codex_events::EventBroadcaster>>, ) -> Result<u64> { // First, fetch the series to get library_id and all books that will be deleted - let series = crate::db::repositories::SeriesRepository::get_by_id(db, series_id) + let series = crate::repositories::SeriesRepository::get_by_id(db, series_id) .await? .context("Series not found")?; @@ -2055,7 +2054,7 @@ impl BookRepository { } // Check if we should purge empty series - let purge_empty_series = crate::db::repositories::SettingsRepository::get_value::<bool>( + let purge_empty_series = crate::repositories::SettingsRepository::get_value::<bool>( db, "purge.purge_empty_series", ) @@ -2065,7 +2064,7 @@ impl BookRepository { if purge_empty_series { // Check if series is now empty and delete it if so - let _series_deleted = crate::db::repositories::SeriesRepository::purge_if_empty( + let _series_deleted = crate::repositories::SeriesRepository::purge_if_empty( db, series_id, event_broadcaster, @@ -2084,7 +2083,7 @@ impl BookRepository { ) -> Result<Vec<books::Model>> { // Get all series in the library let series_list = - crate::db::repositories::SeriesRepository::list_by_library(db, library_id).await?; + crate::repositories::SeriesRepository::list_by_library(db, library_id).await?; let series_ids: Vec<Uuid> = series_list.iter().map(|s| s.id).collect(); if series_ids.is_empty() { @@ -2121,7 +2120,7 @@ impl BookRepository { series_id: Uuid, user_id: Uuid, ) -> Result<i64> { - use crate::db::entities::read_progress; + use crate::entities::read_progress; use sea_orm::JoinType; // Count all non-deleted books in the series @@ -2155,7 +2154,7 @@ impl BookRepository { series_ids: &[Uuid], user_id: Uuid, ) -> Result<std::collections::HashMap<Uuid, i64>> { - use crate::db::entities::read_progress; + use crate::entities::read_progress; use sea_orm::{FromQueryResult, JoinType, QuerySelect, sea_query::Expr}; if series_ids.is_empty() { @@ -2291,7 +2290,7 @@ impl BookRepository { error_type: BookErrorType, error: BookError, ) -> Result<()> { - use crate::db::entities::book_error::{parse_analysis_errors, serialize_analysis_errors}; + use crate::entities::book_error::{parse_analysis_errors, serialize_analysis_errors}; let book = Books::find_by_id(book_id) .one(db) @@ -2327,7 +2326,7 @@ impl BookRepository { book_id: Uuid, error_type: BookErrorType, ) -> Result<()> { - use crate::db::entities::book_error::{parse_analysis_errors, serialize_analysis_errors}; + use crate::entities::book_error::{parse_analysis_errors, serialize_analysis_errors}; let book = Books::find_by_id(book_id) .one(db) @@ -2376,7 +2375,7 @@ impl BookRepository { /// Get all errors for a book pub async fn get_errors(db: &DatabaseConnection, book_id: Uuid) -> Result<BookErrors> { - use crate::db::entities::book_error::parse_analysis_errors; + use crate::entities::book_error::parse_analysis_errors; let book = Books::find_by_id(book_id) .one(db) @@ -2397,7 +2396,7 @@ impl BookRepository { page: u64, page_size: u64, ) -> Result<(Vec<(books::Model, BookErrors)>, u64)> { - use crate::db::entities::book_error::parse_analysis_errors; + use crate::entities::book_error::parse_analysis_errors; let mut query = Books::find() .filter(books::Column::AnalysisErrors.is_not_null()) @@ -2454,7 +2453,7 @@ impl BookRepository { db: &DatabaseConnection, library_id: Option<Uuid>, ) -> Result<std::collections::HashMap<BookErrorType, u64>> { - use crate::db::entities::book_error::parse_analysis_errors; + use crate::entities::book_error::parse_analysis_errors; let mut query = Books::find() .filter(books::Column::AnalysisErrors.is_not_null()) @@ -2668,7 +2667,7 @@ impl BookRepository { cursor: Option<(&str, Uuid)>, page_size: u64, ) -> Result<Vec<books::Model>> { - use crate::db::entities::book_metadata; + use crate::entities::book_metadata; use sea_orm::JoinType; let mut query = Books::find() @@ -2768,7 +2767,7 @@ impl BookRepository { /// Get title_sort for a book (used for cursor construction) pub async fn get_title_sort(db: &DatabaseConnection, book_id: Uuid) -> Result<Option<String>> { - use crate::db::entities::book_metadata; + use crate::entities::book_metadata; let result: Option<String> = book_metadata::Entity::find() .filter(book_metadata::Column::BookId.eq(book_id)) @@ -2786,9 +2785,9 @@ impl BookRepository { #[cfg(test)] mod tests { use super::*; - use crate::db::ScanningStrategy; - use crate::db::repositories::{LibraryRepository, SeriesRepository}; - use crate::db::test_helpers::create_test_db; + use crate::ScanningStrategy; + use crate::repositories::{LibraryRepository, SeriesRepository}; + use crate::test_helpers::create_test_db; /// Helper to create a test book model fn create_book_model( @@ -3377,8 +3376,8 @@ mod tests { // Create user - use crate::db::entities::users; - use crate::db::repositories::{ReadProgressRepository, UserRepository}; + use crate::entities::users; + use crate::repositories::{ReadProgressRepository, UserRepository}; use codex_utils::password; let password_hash = password::hash_password("test123").unwrap(); diff --git a/src/db/repositories/book_covers.rs b/crates/codex-db/src/repositories/book_covers.rs similarity index 98% rename from src/db/repositories/book_covers.rs rename to crates/codex-db/src/repositories/book_covers.rs index b0d0d258..ecb1ad1e 100644 --- a/src/db/repositories/book_covers.rs +++ b/crates/codex-db/src/repositories/book_covers.rs @@ -15,7 +15,7 @@ use sea_orm::{ use std::collections::HashMap; use uuid::Uuid; -use crate::db::entities::{book_covers, book_covers::Entity as BookCovers}; +use crate::entities::{book_covers, book_covers::Entity as BookCovers}; /// Repository for book cover operations pub struct BookCoversRepository; @@ -367,10 +367,10 @@ impl BookCoversRepository { #[cfg(test)] mod tests { use super::*; - use crate::db::ScanningStrategy; - use crate::db::entities::books; - use crate::db::repositories::{BookRepository, LibraryRepository, SeriesRepository}; - use crate::db::test_helpers::create_test_db; + use crate::ScanningStrategy; + use crate::entities::books; + use crate::repositories::{BookRepository, LibraryRepository, SeriesRepository}; + use crate::test_helpers::create_test_db; use chrono::Utc; async fn setup_test_book(db: &DatabaseConnection) -> (Uuid, Uuid) { diff --git a/src/db/repositories/book_duplicates.rs b/crates/codex-db/src/repositories/book_duplicates.rs similarity index 99% rename from src/db/repositories/book_duplicates.rs rename to crates/codex-db/src/repositories/book_duplicates.rs index 91086cd2..de0e281f 100644 --- a/src/db/repositories/book_duplicates.rs +++ b/crates/codex-db/src/repositories/book_duplicates.rs @@ -13,7 +13,7 @@ use sea_orm::{ use tracing::{debug, info}; use uuid::Uuid; -use crate::db::entities::{book_duplicates, prelude::*}; +use crate::entities::{book_duplicates, prelude::*}; /// Repository for BookDuplicates operations pub struct BookDuplicatesRepository; diff --git a/src/db/repositories/book_external_id.rs b/crates/codex-db/src/repositories/book_external_id.rs similarity index 98% rename from src/db/repositories/book_external_id.rs rename to crates/codex-db/src/repositories/book_external_id.rs index 2ba3915f..c2aa8c55 100644 --- a/src/db/repositories/book_external_id.rs +++ b/crates/codex-db/src/repositories/book_external_id.rs @@ -17,7 +17,7 @@ use sea_orm::{ use std::collections::HashMap; use uuid::Uuid; -use crate::db::entities::book_external_ids::{ +use crate::entities::book_external_ids::{ self, Entity as BookExternalIds, Model as BookExternalId, }; @@ -334,10 +334,10 @@ impl BookExternalIdRepository { #[cfg(test)] mod tests { use super::*; - use crate::db::ScanningStrategy; - use crate::db::entities::books; - use crate::db::repositories::{BookRepository, LibraryRepository, SeriesRepository}; - use crate::db::test_helpers::create_test_db; + use crate::ScanningStrategy; + use crate::entities::books; + use crate::repositories::{BookRepository, LibraryRepository, SeriesRepository}; + use crate::test_helpers::create_test_db; use chrono::Utc; async fn setup_test_book(db: &DatabaseConnection) -> (Uuid, Uuid) { diff --git a/src/db/repositories/book_external_links.rs b/crates/codex-db/src/repositories/book_external_links.rs similarity index 98% rename from src/db/repositories/book_external_links.rs rename to crates/codex-db/src/repositories/book_external_links.rs index 5d439960..b61af949 100644 --- a/src/db/repositories/book_external_links.rs +++ b/crates/codex-db/src/repositories/book_external_links.rs @@ -7,9 +7,7 @@ use chrono::Utc; use sea_orm::{ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, Set}; use uuid::Uuid; -use crate::db::entities::book_external_links::{ - self, Entity as ExternalLinks, Model as ExternalLink, -}; +use crate::entities::book_external_links::{self, Entity as ExternalLinks, Model as ExternalLink}; /// Repository for book external link operations pub struct BookExternalLinkRepository; @@ -191,10 +189,10 @@ impl BookExternalLinkRepository { #[cfg(test)] mod tests { use super::*; - use crate::db::ScanningStrategy; - use crate::db::entities::books; - use crate::db::repositories::{BookRepository, LibraryRepository, SeriesRepository}; - use crate::db::test_helpers::create_test_db; + use crate::ScanningStrategy; + use crate::entities::books; + use crate::repositories::{BookRepository, LibraryRepository, SeriesRepository}; + use crate::test_helpers::create_test_db; use chrono::Utc; async fn create_test_book(db: &DatabaseConnection) -> books::Model { diff --git a/src/db/repositories/email_verification_token.rs b/crates/codex-db/src/repositories/email_verification_token.rs similarity index 97% rename from src/db/repositories/email_verification_token.rs rename to crates/codex-db/src/repositories/email_verification_token.rs index 9e8dc4b3..98853017 100644 --- a/src/db/repositories/email_verification_token.rs +++ b/crates/codex-db/src/repositories/email_verification_token.rs @@ -4,7 +4,7 @@ #![allow(dead_code)] -use crate::db::entities::{ +use crate::entities::{ email_verification_tokens, email_verification_tokens::Entity as EmailVerificationToken, }; use anyhow::Result; @@ -112,9 +112,9 @@ impl EmailVerificationTokenRepository { #[cfg(test)] mod tests { use super::*; - use crate::db::entities::users; - use crate::db::repositories::user::UserRepository; - use crate::db::test_helpers::setup_test_db; + use crate::entities::users; + use crate::repositories::user::UserRepository; + use crate::test_helpers::setup_test_db; async fn create_test_user(db: &DatabaseConnection) -> users::Model { let user = users::Model { diff --git a/src/db/repositories/external_link.rs b/crates/codex-db/src/repositories/external_link.rs similarity index 98% rename from src/db/repositories/external_link.rs rename to crates/codex-db/src/repositories/external_link.rs index f4d70931..b14d4f30 100644 --- a/src/db/repositories/external_link.rs +++ b/crates/codex-db/src/repositories/external_link.rs @@ -9,7 +9,7 @@ use chrono::Utc; use sea_orm::{ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, Set}; use uuid::Uuid; -use crate::db::entities::series_external_links::{ +use crate::entities::series_external_links::{ self, Entity as ExternalLinks, Model as ExternalLink, }; @@ -200,9 +200,9 @@ impl ExternalLinkRepository { #[cfg(test)] mod tests { use super::*; - use crate::db::ScanningStrategy; - use crate::db::repositories::{LibraryRepository, SeriesRepository}; - use crate::db::test_helpers::create_test_db; + use crate::ScanningStrategy; + use crate::repositories::{LibraryRepository, SeriesRepository}; + use crate::test_helpers::create_test_db; #[tokio::test] async fn test_create_and_get_external_link() { diff --git a/src/db/repositories/external_rating.rs b/crates/codex-db/src/repositories/external_rating.rs similarity index 98% rename from src/db/repositories/external_rating.rs rename to crates/codex-db/src/repositories/external_rating.rs index 84e120ec..46a31776 100644 --- a/src/db/repositories/external_rating.rs +++ b/crates/codex-db/src/repositories/external_rating.rs @@ -12,7 +12,7 @@ use sea_orm::{ }; use uuid::Uuid; -use crate::db::entities::series_external_ratings::{ +use crate::entities::series_external_ratings::{ self, Entity as ExternalRatings, Model as ExternalRating, }; @@ -208,9 +208,9 @@ impl ExternalRatingRepository { #[cfg(test)] mod tests { use super::*; - use crate::db::ScanningStrategy; - use crate::db::repositories::{LibraryRepository, SeriesRepository}; - use crate::db::test_helpers::create_test_db; + use crate::ScanningStrategy; + use crate::repositories::{LibraryRepository, SeriesRepository}; + use crate::test_helpers::create_test_db; fn dec(value: f64) -> Decimal { Decimal::from_f64_retain(value).unwrap() diff --git a/src/db/repositories/filter_preset.rs b/crates/codex-db/src/repositories/filter_preset.rs similarity index 98% rename from src/db/repositories/filter_preset.rs rename to crates/codex-db/src/repositories/filter_preset.rs index c09e5079..039feba5 100644 --- a/src/db/repositories/filter_preset.rs +++ b/crates/codex-db/src/repositories/filter_preset.rs @@ -4,7 +4,7 @@ //! backs both the library list-page filter panels (`scope = "list"`) and the //! advanced search page (`scope = "search"`). -use crate::db::entities::filter_presets::{self, Entity as FilterPreset}; +use crate::entities::filter_presets::{self, Entity as FilterPreset}; use anyhow::Result; use chrono::Utc; use sea_orm::*; @@ -182,11 +182,11 @@ impl FilterPresetRepository { #[cfg(test)] mod tests { use super::*; - use crate::db::repositories::UserRepository; - use crate::db::test_helpers::setup_test_db; + use crate::repositories::UserRepository; + use crate::test_helpers::setup_test_db; - async fn create_test_user(db: &DatabaseConnection) -> crate::db::entities::users::Model { - let user = crate::db::entities::users::Model { + async fn create_test_user(db: &DatabaseConnection) -> crate::entities::users::Model { + let user = crate::entities::users::Model { id: Uuid::new_v4(), username: format!("preset_user_{}", Uuid::new_v4()), email: format!("preset_{}@example.com", Uuid::new_v4()), diff --git a/src/db/repositories/genre.rs b/crates/codex-db/src/repositories/genre.rs similarity index 96% rename from src/db/repositories/genre.rs rename to crates/codex-db/src/repositories/genre.rs index eaa725ec..d769222b 100644 --- a/src/db/repositories/genre.rs +++ b/crates/codex-db/src/repositories/genre.rs @@ -12,7 +12,7 @@ use sea_orm::{ }; use uuid::Uuid; -use crate::db::entities::{ +use crate::entities::{ book_genres, book_genres::Entity as BookGenres, genres, genres::Entity as Genres, series_genres, }; @@ -81,7 +81,7 @@ impl GenreRepository { db: &DatabaseConnection, series_id: Uuid, ) -> Result<Vec<genres::Model>> { - use crate::db::entities::series_genres::Entity as SeriesGenres; + use crate::entities::series_genres::Entity as SeriesGenres; let genre_ids: Vec<Uuid> = SeriesGenres::find() .filter(series_genres::Column::SeriesId.eq(series_id)) @@ -111,7 +111,7 @@ impl GenreRepository { series_id: Uuid, genre_names: Vec<String>, ) -> Result<Vec<genres::Model>> { - use crate::db::entities::series_genres::Entity as SeriesGenres; + use crate::entities::series_genres::Entity as SeriesGenres; // Remove existing genre links for this series SeriesGenres::delete_many() @@ -152,7 +152,7 @@ impl GenreRepository { let genre = Self::find_or_create(db, genre_name).await?; // Check if already linked - use crate::db::entities::series_genres::Entity as SeriesGenres; + use crate::entities::series_genres::Entity as SeriesGenres; let existing = SeriesGenres::find() .filter(series_genres::Column::SeriesId.eq(series_id)) .filter(series_genres::Column::GenreId.eq(genre.id)) @@ -176,7 +176,7 @@ impl GenreRepository { series_id: Uuid, genre_id: Uuid, ) -> Result<bool> { - use crate::db::entities::series_genres::Entity as SeriesGenres; + use crate::entities::series_genres::Entity as SeriesGenres; let result = SeriesGenres::delete_many() .filter(series_genres::Column::SeriesId.eq(series_id)) @@ -189,7 +189,7 @@ impl GenreRepository { /// Count series using a genre pub async fn count_series_with_genre(db: &DatabaseConnection, genre_id: Uuid) -> Result<u64> { - use crate::db::entities::series_genres::Entity as SeriesGenres; + use crate::entities::series_genres::Entity as SeriesGenres; let count = SeriesGenres::find() .filter(series_genres::Column::GenreId.eq(genre_id)) @@ -204,7 +204,7 @@ impl GenreRepository { db: &DatabaseConnection, genre_name: &str, ) -> Result<Vec<Uuid>> { - use crate::db::entities::series_genres::Entity as SeriesGenres; + use crate::entities::series_genres::Entity as SeriesGenres; let normalized = genre_name.to_lowercase().trim().to_string(); @@ -269,7 +269,7 @@ impl GenreRepository { db: &DatabaseConnection, substring: &str, ) -> Result<Vec<Uuid>> { - use crate::db::entities::series_genres::Entity as SeriesGenres; + use crate::entities::series_genres::Entity as SeriesGenres; let normalized = substring.to_lowercase(); @@ -304,7 +304,7 @@ impl GenreRepository { db: &DatabaseConnection, prefix: &str, ) -> Result<Vec<Uuid>> { - use crate::db::entities::series_genres::Entity as SeriesGenres; + use crate::entities::series_genres::Entity as SeriesGenres; let normalized = prefix.to_lowercase(); @@ -336,7 +336,7 @@ impl GenreRepository { db: &DatabaseConnection, suffix: &str, ) -> Result<Vec<Uuid>> { - use crate::db::entities::series_genres::Entity as SeriesGenres; + use crate::entities::series_genres::Entity as SeriesGenres; let normalized = suffix.to_lowercase(); @@ -365,7 +365,7 @@ impl GenreRepository { /// Get all series IDs that have at least one genre pub async fn get_all_series_with_genres(db: &DatabaseConnection) -> Result<Vec<Uuid>> { - use crate::db::entities::series_genres::Entity as SeriesGenres; + use crate::entities::series_genres::Entity as SeriesGenres; let series_ids: Vec<Uuid> = SeriesGenres::find() .all(db) @@ -386,7 +386,7 @@ impl GenreRepository { db: &DatabaseConnection, series_ids: &[Uuid], ) -> Result<std::collections::HashMap<Uuid, Vec<genres::Model>>> { - use crate::db::entities::series_genres::Entity as SeriesGenres; + use crate::entities::series_genres::Entity as SeriesGenres; if series_ids.is_empty() { return Ok(std::collections::HashMap::new()); @@ -605,7 +605,7 @@ impl GenreRepository { /// Delete all unused genres (genres with no series or books linked) /// Returns the names of deleted genres pub async fn delete_unused(db: &DatabaseConnection) -> Result<Vec<String>> { - use crate::db::entities::series_genres::Entity as SeriesGenres; + use crate::entities::series_genres::Entity as SeriesGenres; // Get all genres let all_genres = Self::list_all(db).await?; @@ -638,9 +638,9 @@ impl GenreRepository { #[cfg(test)] mod tests { use super::*; - use crate::db::ScanningStrategy; - use crate::db::repositories::{LibraryRepository, SeriesRepository}; - use crate::db::test_helpers::create_test_db; + use crate::ScanningStrategy; + use crate::repositories::{LibraryRepository, SeriesRepository}; + use crate::test_helpers::create_test_db; #[tokio::test] async fn test_create_and_get_genre() { @@ -978,12 +978,12 @@ mod tests { /// Helper to create a test book for genre tests async fn create_test_book_for_genre( - db: &crate::db::Database, + db: &crate::Database, series_id: Uuid, library_id: Uuid, - ) -> crate::db::entities::books::Model { - use crate::db::entities::books; - use crate::db::repositories::BookRepository; + ) -> crate::entities::books::Model { + use crate::entities::books; + use crate::repositories::BookRepository; use chrono::Utc; let book = books::Model { diff --git a/src/db/repositories/library.rs b/crates/codex-db/src/repositories/library.rs similarity index 98% rename from src/db/repositories/library.rs rename to crates/codex-db/src/repositories/library.rs index 1e356b05..48d078a4 100644 --- a/src/db/repositories/library.rs +++ b/crates/codex-db/src/repositories/library.rs @@ -11,8 +11,8 @@ use sea_orm::{ }; use uuid::Uuid; -use crate::db::entities::{libraries, prelude::*}; -use crate::observability::repo::db_system_str; +use crate::entities::{libraries, prelude::*}; +use crate::trace::db_system_str; use codex_models::{BookStrategy, NumberStrategy, SeriesStrategy}; /// Parameters for creating a new library @@ -152,7 +152,7 @@ impl LibraryRepository { db: &DatabaseConnection, name: &str, path: &str, - _strategy: crate::db::ScanningStrategy, // Legacy parameter, ignored + _strategy: crate::ScanningStrategy, // Legacy parameter, ignored ) -> Result<libraries::Model> { let params = CreateLibraryParams::new(name, path); Self::create_with_params(db, params).await @@ -390,8 +390,8 @@ impl LibraryRepository { #[cfg(test)] mod tests { use super::*; - use crate::db::ScanningStrategy; - use crate::db::test_helpers::create_test_db; + use crate::ScanningStrategy; + use crate::test_helpers::create_test_db; #[tokio::test] async fn test_create_library() { @@ -597,7 +597,7 @@ mod tests { #[tokio::test] async fn test_delete_library_also_deletes_task_metrics() { - use crate::db::repositories::task_metrics::{TaskCompletionData, TaskMetricsRepository}; + use crate::repositories::task_metrics::{TaskCompletionData, TaskMetricsRepository}; let (db, _temp_dir) = create_test_db().await; diff --git a/src/db/repositories/library_jobs.rs b/crates/codex-db/src/repositories/library_jobs.rs similarity index 98% rename from src/db/repositories/library_jobs.rs rename to crates/codex-db/src/repositories/library_jobs.rs index ad633f7a..d31f5665 100644 --- a/src/db/repositories/library_jobs.rs +++ b/crates/codex-db/src/repositories/library_jobs.rs @@ -11,7 +11,7 @@ use sea_orm::{ }; use uuid::Uuid; -use crate::db::entities::{library_jobs, prelude::*}; +use crate::entities::{library_jobs, prelude::*}; /// Parameters for creating a new library job row. #[derive(Debug, Clone)] @@ -174,9 +174,9 @@ impl LibraryJobRepository { #[cfg(test)] mod tests { use super::*; - use crate::db::ScanningStrategy; - use crate::db::repositories::LibraryRepository; - use crate::db::test_helpers::create_test_db; + use crate::ScanningStrategy; + use crate::repositories::LibraryRepository; + use crate::test_helpers::create_test_db; async fn seed_library(db: &DatabaseConnection, name: &str, path: &str) -> Uuid { LibraryRepository::create(db, name, path, ScanningStrategy::Default) @@ -371,7 +371,7 @@ mod tests { #[tokio::test] async fn cascade_delete_removes_jobs_when_library_deleted() { - use crate::db::entities::libraries::Entity as Libs; + use crate::entities::libraries::Entity as Libs; let (db, _tmp) = create_test_db().await; let lib = seed_library(db.sea_orm_connection(), "L", "/p").await; diff --git a/src/db/repositories/metadata.rs b/crates/codex-db/src/repositories/metadata.rs similarity index 98% rename from src/db/repositories/metadata.rs rename to crates/codex-db/src/repositories/metadata.rs index 79f70b3a..dbfcd740 100644 --- a/src/db/repositories/metadata.rs +++ b/crates/codex-db/src/repositories/metadata.rs @@ -9,7 +9,7 @@ use chrono::Utc; use sea_orm::{ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, Set}; use uuid::Uuid; -use crate::db::entities::{book_metadata, prelude::*}; +use crate::entities::{book_metadata, prelude::*}; use codex_utils::normalize_for_search; /// Repository for BookMetadata operations @@ -416,13 +416,13 @@ impl BookMetadataRepository { #[cfg(test)] mod tests { use super::*; - use crate::db::ScanningStrategy; - use crate::db::repositories::{BookRepository, LibraryRepository, SeriesRepository}; - use crate::db::test_helpers::create_test_db; + use crate::ScanningStrategy; + use crate::repositories::{BookRepository, LibraryRepository, SeriesRepository}; + use crate::test_helpers::create_test_db; use chrono::Utc; /// Helper to create a test book - async fn create_test_book(db: &crate::db::Database) -> crate::db::entities::books::Model { + async fn create_test_book(db: &crate::Database) -> crate::entities::books::Model { let library = LibraryRepository::create( db.sea_orm_connection(), "Test Library", @@ -437,7 +437,7 @@ mod tests { .await .unwrap(); - let book = crate::db::entities::books::Model { + let book = crate::entities::books::Model { id: Uuid::new_v4(), series_id: series.id, library_id: library.id, diff --git a/src/db/repositories/metrics.rs b/crates/codex-db/src/repositories/metrics.rs similarity index 97% rename from src/db/repositories/metrics.rs rename to crates/codex-db/src/repositories/metrics.rs index b3587931..0ae45056 100644 --- a/src/db/repositories/metrics.rs +++ b/crates/codex-db/src/repositories/metrics.rs @@ -5,7 +5,7 @@ use sea_orm::{ }; use uuid::Uuid; -use crate::db::entities::prelude::*; +use crate::entities::prelude::*; /// Repository for gathering application metrics pub struct MetricsRepository; @@ -68,7 +68,7 @@ impl MetricsRepository { /// Get total size of all books in bytes pub async fn total_book_size(db: &DatabaseConnection) -> Result<i64> { - use crate::db::entities::books; + use crate::entities::books; use sea_orm::DbBackend; use sea_orm::prelude::Decimal; use sea_orm::sea_query::{Expr, Func}; @@ -182,7 +182,7 @@ impl MetricsRepository { /// Get metrics broken down by library pub async fn library_metrics(db: &DatabaseConnection) -> Result<Vec<LibraryMetrics>> { - use crate::db::entities::series; + use crate::entities::series; use sea_orm::{ColumnTrait, QueryFilter}; // Get all libraries @@ -204,7 +204,7 @@ impl MetricsRepository { // Count books and total size for this library // We need to join books with series to filter by library - use crate::db::entities::books; + use crate::entities::books; use sea_orm::DbBackend; use sea_orm::prelude::Decimal; use sea_orm::sea_query::{Alias, Expr, Func}; @@ -294,10 +294,10 @@ impl MetricsRepository { #[cfg(test)] mod tests { use super::*; - use crate::db::ScanningStrategy; - use crate::db::entities::books; - use crate::db::repositories::{BookRepository, LibraryRepository, SeriesRepository}; - use crate::db::test_helpers::create_test_db; + use crate::ScanningStrategy; + use crate::entities::books; + use crate::repositories::{BookRepository, LibraryRepository, SeriesRepository}; + use crate::test_helpers::create_test_db; use chrono::Utc; use uuid::Uuid; diff --git a/src/db/repositories/mod.rs b/crates/codex-db/src/repositories/mod.rs similarity index 100% rename from src/db/repositories/mod.rs rename to crates/codex-db/src/repositories/mod.rs diff --git a/src/db/repositories/oidc_connection.rs b/crates/codex-db/src/repositories/oidc_connection.rs similarity index 98% rename from src/db/repositories/oidc_connection.rs rename to crates/codex-db/src/repositories/oidc_connection.rs index fbc53863..3202c275 100644 --- a/src/db/repositories/oidc_connection.rs +++ b/crates/codex-db/src/repositories/oidc_connection.rs @@ -3,7 +3,7 @@ //! This repository handles CRUD operations and lookups for OIDC connections, //! which link Codex users to their external identity provider accounts. -use crate::db::entities::oidc_connections::{self, Entity as OidcConnection}; +use crate::entities::oidc_connections::{self, Entity as OidcConnection}; use anyhow::Result; use chrono::Utc; use sea_orm::*; @@ -197,9 +197,9 @@ impl OidcConnectionRepository { #[cfg(test)] mod tests { use super::*; - use crate::db::entities::users; - use crate::db::repositories::UserRepository; - use crate::db::test_helpers::setup_test_db; + use crate::entities::users; + use crate::repositories::UserRepository; + use crate::test_helpers::setup_test_db; async fn create_test_user(db: &DatabaseConnection) -> users::Model { let user = users::Model { diff --git a/src/db/repositories/page.rs b/crates/codex-db/src/repositories/page.rs similarity index 96% rename from src/db/repositories/page.rs rename to crates/codex-db/src/repositories/page.rs index 15aa8a31..8194f5e3 100644 --- a/src/db/repositories/page.rs +++ b/crates/codex-db/src/repositories/page.rs @@ -10,7 +10,7 @@ use sea_orm::{ }; use uuid::Uuid; -use crate::db::entities::{pages, prelude::*}; +use crate::entities::{pages, prelude::*}; /// Repository for Page operations pub struct PageRepository; @@ -124,9 +124,9 @@ impl PageRepository { #[cfg(test)] mod tests { use super::*; - use crate::db::ScanningStrategy; - use crate::db::repositories::{BookRepository, LibraryRepository, SeriesRepository}; - use crate::db::test_helpers::create_test_db; + use crate::ScanningStrategy; + use crate::repositories::{BookRepository, LibraryRepository, SeriesRepository}; + use crate::test_helpers::create_test_db; use chrono::Utc; /// Helper to create a test page model @@ -162,7 +162,7 @@ mod tests { .await .unwrap(); - let book = crate::db::entities::books::Model { + let book = crate::entities::books::Model { id: Uuid::new_v4(), series_id: series.id, library_id: library.id, @@ -219,7 +219,7 @@ mod tests { .await .unwrap(); - let book = crate::db::entities::books::Model { + let book = crate::entities::books::Model { id: Uuid::new_v4(), series_id: series.id, library_id: library.id, @@ -279,7 +279,7 @@ mod tests { .await .unwrap(); - let book = crate::db::entities::books::Model { + let book = crate::entities::books::Model { id: Uuid::new_v4(), series_id: series.id, library_id: library.id, @@ -339,7 +339,7 @@ mod tests { .await .unwrap(); - let book = crate::db::entities::books::Model { + let book = crate::entities::books::Model { id: Uuid::new_v4(), series_id: series.id, library_id: library.id, @@ -401,7 +401,7 @@ mod tests { .await .unwrap(); - let book = crate::db::entities::books::Model { + let book = crate::entities::books::Model { id: Uuid::new_v4(), series_id: series.id, library_id: library.id, @@ -462,7 +462,7 @@ mod tests { .await .unwrap(); - let book = crate::db::entities::books::Model { + let book = crate::entities::books::Model { id: Uuid::new_v4(), series_id: series.id, library_id: library.id, @@ -528,7 +528,7 @@ mod tests { .await .unwrap(); - let book = crate::db::entities::books::Model { + let book = crate::entities::books::Model { id: Uuid::new_v4(), series_id: series.id, library_id: library.id, diff --git a/src/db/repositories/plugin_failures.rs b/crates/codex-db/src/repositories/plugin_failures.rs similarity index 99% rename from src/db/repositories/plugin_failures.rs rename to crates/codex-db/src/repositories/plugin_failures.rs index 3b315346..c77d60b5 100644 --- a/src/db/repositories/plugin_failures.rs +++ b/crates/codex-db/src/repositories/plugin_failures.rs @@ -11,7 +11,7 @@ //! - Get recent failures for debugging //! - Cleanup expired failures -use crate::db::entities::plugin_failures::{self, Entity as PluginFailures}; +use crate::entities::plugin_failures::{self, Entity as PluginFailures}; use anyhow::Result; use chrono::{Duration, Utc}; use sea_orm::*; @@ -290,9 +290,9 @@ impl PluginFailuresRepository { #[cfg(test)] mod tests { use super::*; - use crate::db::entities::plugin_failures::error_codes; - use crate::db::repositories::PluginsRepository; - use crate::db::test_helpers::setup_test_db; + use crate::entities::plugin_failures::error_codes; + use crate::repositories::PluginsRepository; + use crate::test_helpers::setup_test_db; use codex_models::plugin::PluginScope; use std::env; use tokio::time::sleep; diff --git a/src/db/repositories/plugins.rs b/crates/codex-db/src/repositories/plugins.rs similarity index 99% rename from src/db/repositories/plugins.rs rename to crates/codex-db/src/repositories/plugins.rs index 42adc0f8..e7247561 100644 --- a/src/db/repositories/plugins.rs +++ b/crates/codex-db/src/repositories/plugins.rs @@ -14,8 +14,8 @@ #![allow(dead_code)] -use crate::db::entities::plugins::{self, Entity as Plugins, PluginPermission}; -use crate::observability::repo::db_system_str; +use crate::entities::plugins::{self, Entity as Plugins, PluginPermission}; +use crate::trace::db_system_str; use anyhow::{Result, anyhow}; use chrono::Utc; use codex_models::plugin::{PluginManifest, PluginScope}; @@ -808,7 +808,7 @@ impl PluginsRepository { #[cfg(test)] mod tests { use super::*; - use crate::db::test_helpers::setup_test_db; + use crate::test_helpers::setup_test_db; use std::env; fn setup_test_encryption_key() { diff --git a/src/db/repositories/read_progress.rs b/crates/codex-db/src/repositories/read_progress.rs similarity index 99% rename from src/db/repositories/read_progress.rs rename to crates/codex-db/src/repositories/read_progress.rs index 55f9a6ac..468847ec 100644 --- a/src/db/repositories/read_progress.rs +++ b/crates/codex-db/src/repositories/read_progress.rs @@ -4,7 +4,7 @@ #![allow(dead_code)] -use crate::db::entities::{read_progress, read_progress::Entity as ReadProgress}; +use crate::entities::{read_progress, read_progress::Entity as ReadProgress}; use anyhow::Result; use chrono::Utc; use sea_orm::*; @@ -319,11 +319,11 @@ impl ReadProgressRepository { mod tests { use super::*; - use crate::db::entities::{books, users}; - use crate::db::repositories::{ + use crate::entities::{books, users}; + use crate::repositories::{ BookRepository, LibraryRepository, SeriesRepository, UserRepository, }; - use crate::db::test_helpers::setup_test_db; + use crate::test_helpers::setup_test_db; use codex_models::ScanningStrategy; use codex_utils::password; diff --git a/src/db/repositories/refresh_token.rs b/crates/codex-db/src/repositories/refresh_token.rs similarity index 98% rename from src/db/repositories/refresh_token.rs rename to crates/codex-db/src/repositories/refresh_token.rs index 1b5cb3f5..416b810f 100644 --- a/src/db/repositories/refresh_token.rs +++ b/crates/codex-db/src/repositories/refresh_token.rs @@ -6,7 +6,7 @@ #![allow(dead_code)] -use crate::db::entities::{refresh_tokens, refresh_tokens::Entity as RefreshToken}; +use crate::entities::{refresh_tokens, refresh_tokens::Entity as RefreshToken}; use anyhow::Result; use chrono::{DateTime, Utc}; use sea_orm::sea_query::Expr; diff --git a/src/db/repositories/release_ledger.rs b/crates/codex-db/src/repositories/release_ledger.rs similarity index 98% rename from src/db/repositories/release_ledger.rs rename to crates/codex-db/src/repositories/release_ledger.rs index 8440a481..a19e1262 100644 --- a/src/db/repositories/release_ledger.rs +++ b/crates/codex-db/src/repositories/release_ledger.rs @@ -15,7 +15,7 @@ use sea_orm::{ }; use uuid::Uuid; -use crate::db::entities::release_ledger::{ +use crate::entities::release_ledger::{ self, Entity as ReleaseLedger, Model as ReleaseLedgerRow, state, }; use codex_models::release::{NumericSpan, normalize_spans, primary_value}; @@ -254,7 +254,7 @@ impl ReleaseLedgerRepository { use sea_orm::{JoinType, RelationTrait}; let mut query = ReleaseLedger::find() .join(JoinType::InnerJoin, release_ledger::Relation::Series.def()) - .order_by_asc(crate::db::entities::series::Column::Name) + .order_by_asc(crate::entities::series::Column::Name) .order_by_asc(release_ledger::Column::SeriesId) .order_by_with_nulls( release_ledger::Column::Volume, @@ -308,11 +308,11 @@ impl ReleaseLedgerRepository { let mut query = ReleaseLedger::find() .select_only() .column(release_ledger::Column::SeriesId) - .column(crate::db::entities::series::Column::LibraryId) + .column(crate::entities::series::Column::LibraryId) .column_as(release_ledger::Column::Id.count(), "count") .join(JoinType::InnerJoin, release_ledger::Relation::Series.def()) .group_by(release_ledger::Column::SeriesId) - .group_by(crate::db::entities::series::Column::LibraryId); + .group_by(crate::entities::series::Column::LibraryId); query = apply_inbox_filter(query, &filter, true); let rows = query.into_model::<Row>().all(db).await?; Ok(rows @@ -339,10 +339,10 @@ impl ReleaseLedgerRepository { } let mut query = ReleaseLedger::find() .select_only() - .column(crate::db::entities::series::Column::LibraryId) + .column(crate::entities::series::Column::LibraryId) .column_as(release_ledger::Column::Id.count(), "count") .join(JoinType::InnerJoin, release_ledger::Relation::Series.def()) - .group_by(crate::db::entities::series::Column::LibraryId); + .group_by(crate::entities::series::Column::LibraryId); query = apply_inbox_filter(query, &filter, true); let rows = query.into_model::<Row>().all(db).await?; Ok(rows @@ -535,7 +535,7 @@ where if !series_already_joined { query = query.join(JoinType::InnerJoin, release_ledger::Relation::Series.def()); } - query = query.filter(crate::db::entities::series::Column::LibraryId.eq(lib_id)); + query = query.filter(crate::entities::series::Column::LibraryId.eq(lib_id)); } query } @@ -543,12 +543,12 @@ where #[cfg(test)] mod tests { use super::*; - use crate::db::ScanningStrategy; - use crate::db::entities::release_sources::kind; - use crate::db::repositories::{ + use crate::ScanningStrategy; + use crate::entities::release_sources::kind; + use crate::repositories::{ LibraryRepository, NewReleaseSource, ReleaseSourceRepository, SeriesRepository, }; - use crate::db::test_helpers::create_test_db; + use crate::test_helpers::create_test_db; async fn setup_world(db: &DatabaseConnection) -> (Uuid, Uuid) { let library = LibraryRepository::create(db, "Lib", "/lib", ScanningStrategy::Default) diff --git a/src/db/repositories/release_sources.rs b/crates/codex-db/src/repositories/release_sources.rs similarity index 99% rename from src/db/repositories/release_sources.rs rename to crates/codex-db/src/repositories/release_sources.rs index b6348078..bee1e746 100644 --- a/src/db/repositories/release_sources.rs +++ b/crates/codex-db/src/repositories/release_sources.rs @@ -16,10 +16,10 @@ use sea_orm::{ }; use uuid::Uuid; -use crate::db::entities::release_sources::{ +use crate::entities::release_sources::{ self, Entity as ReleaseSources, Model as ReleaseSource, kind, plugin_id as source_plugin_id, }; -use crate::db::repositories::plugins::PluginsRepository; +use crate::repositories::plugins::PluginsRepository; use codex_utils::cron::validate_cron_expression; /// Normalize a caller-supplied cron schedule: trim, treat empty as `None`, @@ -429,7 +429,7 @@ impl ReleaseSourceRepository { #[cfg(test)] mod tests { use super::*; - use crate::db::test_helpers::create_test_db; + use crate::test_helpers::create_test_db; fn nyaa_source() -> NewReleaseSource { NewReleaseSource { diff --git a/src/db/repositories/series.rs b/crates/codex-db/src/repositories/series.rs similarity index 99% rename from src/db/repositories/series.rs rename to crates/codex-db/src/repositories/series.rs index 932a7b92..1ff30b5e 100644 --- a/src/db/repositories/series.rs +++ b/crates/codex-db/src/repositories/series.rs @@ -13,11 +13,11 @@ use sea_orm::{ }; use uuid::Uuid; -use crate::db::entities::{ +use crate::entities::{ book_metadata, books, prelude::*, read_progress, series, series_external_ratings, series_metadata, user_series_ratings, }; -use crate::observability::repo::db_system_str; +use crate::trace::db_system_str; use codex_events::{EntityChangeEvent, EntityEvent, EventBroadcaster}; use codex_models::sort::{SeriesSortField, SeriesSortParam, SortDirection}; use codex_utils::normalize_for_search; @@ -1641,7 +1641,7 @@ impl SeriesRepository { user_id: Uuid, library_id: Option<Uuid>, ) -> Result<Vec<series::Model>> { - use crate::db::entities::{books, read_progress}; + use crate::entities::{books, read_progress}; use sea_orm::JoinType; let mut query = Series::find() @@ -1803,7 +1803,7 @@ impl SeriesRepository { /// Update series name/title (updates series_metadata.title) /// Note: This now updates the title in series_metadata, not the series table pub async fn update_name(db: &DatabaseConnection, id: Uuid, name: &str) -> Result<()> { - use crate::db::repositories::SeriesMetadataRepository; + use crate::repositories::SeriesMetadataRepository; // Update the title in series_metadata. // No broadcaster wired through `update_name`'s signature today; the @@ -2221,7 +2221,7 @@ impl SeriesRepository { // Remove this series from any duplicate group it participates in before // dropping the row, since series_duplicates.series_ids is a JSON text // column (no FK cascade available). - use crate::db::repositories::SeriesDuplicatesRepository; + use crate::repositories::SeriesDuplicatesRepository; SeriesDuplicatesRepository::cleanup_for_series(db, id).await?; Series::delete_by_id(id) @@ -2237,7 +2237,7 @@ impl SeriesRepository { library_id: Uuid, event_broadcaster: Option<&Arc<codex_events::EventBroadcaster>>, ) -> Result<u64> { - use crate::db::entities::{books, prelude::*}; + use crate::entities::{books, prelude::*}; // Find all series in the library let all_series = Series::find() @@ -2259,7 +2259,7 @@ impl SeriesRepository { if book_count == 0 { let series_id = series_model.id; - use crate::db::repositories::SeriesDuplicatesRepository; + use crate::repositories::SeriesDuplicatesRepository; SeriesDuplicatesRepository::cleanup_for_series(db, series_id).await?; Series::delete_by_id(series_id) @@ -2301,7 +2301,7 @@ impl SeriesRepository { series_id: Uuid, event_broadcaster: Option<&Arc<codex_events::EventBroadcaster>>, ) -> Result<bool> { - use crate::db::entities::books; + use crate::entities::books; // First get series info for library_id before deletion let series = Self::get_by_id(db, series_id) @@ -2318,7 +2318,7 @@ impl SeriesRepository { if book_count == 0 { // Series is empty, delete it - use crate::db::repositories::SeriesDuplicatesRepository; + use crate::repositories::SeriesDuplicatesRepository; SeriesDuplicatesRepository::cleanup_for_series(db, series_id).await?; Series::delete_by_id(series_id) @@ -2494,10 +2494,10 @@ impl SeriesRepository { #[cfg(test)] mod tests { use super::*; - use crate::db::ScanningStrategy; - use crate::db::entities::books; - use crate::db::repositories::{BookRepository, LibraryRepository, SeriesMetadataRepository}; - use crate::db::test_helpers::create_test_db; + use crate::ScanningStrategy; + use crate::entities::books; + use crate::repositories::{BookRepository, LibraryRepository, SeriesMetadataRepository}; + use crate::test_helpers::create_test_db; #[tokio::test] async fn test_create_series() { @@ -2520,7 +2520,7 @@ mod tests { assert_eq!(series.library_id, library.id); // Title is now in series_metadata - let metadata = crate::db::repositories::SeriesMetadataRepository::get_by_series_id( + let metadata = crate::repositories::SeriesMetadataRepository::get_by_series_id( db.sea_orm_connection(), series.id, ) @@ -2610,7 +2610,7 @@ mod tests { assert_eq!(results.len(), 1); // Verify the result by checking metadata - let metadata = crate::db::repositories::SeriesMetadataRepository::get_by_series_id( + let metadata = crate::repositories::SeriesMetadataRepository::get_by_series_id( db.sea_orm_connection(), results[0].id, ) @@ -2780,7 +2780,7 @@ mod tests { .unwrap(); // Verify metadata was updated - let metadata = crate::db::repositories::SeriesMetadataRepository::get_by_series_id( + let metadata = crate::repositories::SeriesMetadataRepository::get_by_series_id( db.sea_orm_connection(), series.id, ) @@ -2999,8 +2999,8 @@ mod tests { // Create user - use crate::db::entities::users; - use crate::db::repositories::{ReadProgressRepository, UserRepository}; + use crate::entities::users; + use crate::repositories::{ReadProgressRepository, UserRepository}; use codex_utils::password; let password_hash = password::hash_password("test123").unwrap(); @@ -3502,7 +3502,7 @@ mod tests { assert_eq!(series.name, "One Piece (Digital)"); // series_metadata.title should be the preprocessed title - let metadata = crate::db::repositories::SeriesMetadataRepository::get_by_series_id( + let metadata = crate::repositories::SeriesMetadataRepository::get_by_series_id( db.sea_orm_connection(), series.id, ) @@ -3542,7 +3542,7 @@ mod tests { assert_eq!(series.name, "One Piece"); // series_metadata.title should also be the original name - let metadata = crate::db::repositories::SeriesMetadataRepository::get_by_series_id( + let metadata = crate::repositories::SeriesMetadataRepository::get_by_series_id( db.sea_orm_connection(), series.id, ) @@ -3562,7 +3562,7 @@ mod tests { volume: Option<i32>, chapter: Option<f32>, ) -> Uuid { - use crate::db::repositories::BookMetadataRepository; + use crate::repositories::BookMetadataRepository; use sea_orm::{ActiveModelTrait, Set}; let book = books::Model { diff --git a/src/db/repositories/series_aliases.rs b/crates/codex-db/src/repositories/series_aliases.rs similarity index 98% rename from src/db/repositories/series_aliases.rs rename to crates/codex-db/src/repositories/series_aliases.rs index fdf595de..db8f235e 100644 --- a/src/db/repositories/series_aliases.rs +++ b/crates/codex-db/src/repositories/series_aliases.rs @@ -16,7 +16,7 @@ use sea_orm::{ use std::collections::HashMap; use uuid::Uuid; -use crate::db::entities::series_aliases::{ +use crate::entities::series_aliases::{ self, Entity as SeriesAliases, Model as SeriesAlias, alias_source, normalize_alias, }; @@ -200,9 +200,9 @@ impl SeriesAliasRepository { #[cfg(test)] mod tests { use super::*; - use crate::db::ScanningStrategy; - use crate::db::repositories::{LibraryRepository, SeriesRepository}; - use crate::db::test_helpers::create_test_db; + use crate::ScanningStrategy; + use crate::repositories::{LibraryRepository, SeriesRepository}; + use crate::test_helpers::create_test_db; async fn make_two_series(db: &DatabaseConnection) -> (Uuid, Uuid) { let library = LibraryRepository::create(db, "Lib", "/lib", ScanningStrategy::Default) diff --git a/src/db/repositories/series_covers.rs b/crates/codex-db/src/repositories/series_covers.rs similarity index 98% rename from src/db/repositories/series_covers.rs rename to crates/codex-db/src/repositories/series_covers.rs index 0a0d6c0c..05c99e2d 100644 --- a/src/db/repositories/series_covers.rs +++ b/crates/codex-db/src/repositories/series_covers.rs @@ -11,7 +11,7 @@ use sea_orm::{ }; use uuid::Uuid; -use crate::db::entities::{series_covers, series_covers::Entity as SeriesCovers}; +use crate::entities::{series_covers, series_covers::Entity as SeriesCovers}; /// Repository for series cover operations pub struct SeriesCoversRepository; @@ -299,9 +299,9 @@ impl SeriesCoversRepository { #[cfg(test)] mod tests { use super::*; - use crate::db::ScanningStrategy; - use crate::db::repositories::{LibraryRepository, SeriesRepository}; - use crate::db::test_helpers::create_test_db; + use crate::ScanningStrategy; + use crate::repositories::{LibraryRepository, SeriesRepository}; + use crate::test_helpers::create_test_db; #[tokio::test] async fn test_create_and_list_covers() { diff --git a/src/db/repositories/series_duplicates.rs b/crates/codex-db/src/repositories/series_duplicates.rs similarity index 99% rename from src/db/repositories/series_duplicates.rs rename to crates/codex-db/src/repositories/series_duplicates.rs index 654c5220..466092bf 100644 --- a/src/db/repositories/series_duplicates.rs +++ b/crates/codex-db/src/repositories/series_duplicates.rs @@ -22,8 +22,8 @@ use sea_orm::{ use tracing::{debug, info}; use uuid::Uuid; -use crate::db::entities::prelude::SeriesDuplicates; -use crate::db::entities::series_duplicates::{self, MATCH_TYPE_EXTERNAL_ID, MATCH_TYPE_TITLE}; +use crate::entities::prelude::SeriesDuplicates; +use crate::entities::series_duplicates::{self, MATCH_TYPE_EXTERNAL_ID, MATCH_TYPE_TITLE}; /// Repository for SeriesDuplicates operations pub struct SeriesDuplicatesRepository; diff --git a/src/db/repositories/series_export.rs b/crates/codex-db/src/repositories/series_export.rs similarity index 98% rename from src/db/repositories/series_export.rs rename to crates/codex-db/src/repositories/series_export.rs index 55cd7c7d..23c774dd 100644 --- a/src/db/repositories/series_export.rs +++ b/crates/codex-db/src/repositories/series_export.rs @@ -2,7 +2,7 @@ //! //! CRUD and query operations for series export jobs. -use crate::db::entities::series_exports::{self, Entity as SeriesExport}; +use crate::entities::series_exports::{self, Entity as SeriesExport}; use anyhow::Result; use chrono::{DateTime, Utc}; use sea_orm::*; @@ -254,12 +254,12 @@ impl SeriesExportRepository { #[cfg(test)] mod tests { use super::*; - use crate::db::repositories::UserRepository; - use crate::db::test_helpers::setup_test_db; + use crate::repositories::UserRepository; + use crate::test_helpers::setup_test_db; use chrono::Duration; - async fn create_test_user(db: &DatabaseConnection) -> crate::db::entities::users::Model { - let user = crate::db::entities::users::Model { + async fn create_test_user(db: &DatabaseConnection) -> crate::entities::users::Model { + let user = crate::entities::users::Model { id: Uuid::new_v4(), username: format!("export_user_{}", Uuid::new_v4()), email: format!("export_{}@example.com", Uuid::new_v4()), diff --git a/src/db/repositories/series_external_id.rs b/crates/codex-db/src/repositories/series_external_id.rs similarity index 99% rename from src/db/repositories/series_external_id.rs rename to crates/codex-db/src/repositories/series_external_id.rs index 984dffb2..18f1fd85 100644 --- a/src/db/repositories/series_external_id.rs +++ b/crates/codex-db/src/repositories/series_external_id.rs @@ -15,7 +15,7 @@ use sea_orm::{ use std::collections::HashMap; use uuid::Uuid; -use crate::db::entities::series_external_ids::{ +use crate::entities::series_external_ids::{ self, Entity as SeriesExternalIds, Model as SeriesExternalId, }; @@ -330,9 +330,9 @@ impl SeriesExternalIdRepository { #[cfg(test)] mod tests { use super::*; - use crate::db::ScanningStrategy; - use crate::db::repositories::{LibraryRepository, SeriesRepository}; - use crate::db::test_helpers::create_test_db; + use crate::ScanningStrategy; + use crate::repositories::{LibraryRepository, SeriesRepository}; + use crate::test_helpers::create_test_db; #[tokio::test] async fn test_create_and_get_external_id() { diff --git a/src/db/repositories/series_metadata.rs b/crates/codex-db/src/repositories/series_metadata.rs similarity index 98% rename from src/db/repositories/series_metadata.rs rename to crates/codex-db/src/repositories/series_metadata.rs index c089f8e6..b9fb8065 100644 --- a/src/db/repositories/series_metadata.rs +++ b/crates/codex-db/src/repositories/series_metadata.rs @@ -10,7 +10,7 @@ use sea_orm::{ActiveModelTrait, DatabaseConnection, EntityTrait, Set}; use std::sync::Arc; use uuid::Uuid; -use crate::db::entities::{series_metadata, series_metadata::Entity as SeriesMetadata}; +use crate::entities::{series_metadata, series_metadata::Entity as SeriesMetadata}; use codex_events::{EntityChangeEvent, EntityEvent, EventBroadcaster}; use codex_utils::normalize_for_search; @@ -529,8 +529,7 @@ async fn emit_metadata_updated( let Some(broadcaster) = broadcaster else { return; }; - let library_id = match crate::db::repositories::SeriesRepository::get_by_id(db, series_id).await - { + let library_id = match crate::repositories::SeriesRepository::get_by_id(db, series_id).await { Ok(Some(series)) => series.library_id, Ok(None) => { tracing::debug!( @@ -563,9 +562,9 @@ async fn emit_metadata_updated( #[cfg(test)] mod tests { use super::*; - use crate::db::ScanningStrategy; - use crate::db::repositories::{LibraryRepository, SeriesRepository}; - use crate::db::test_helpers::create_test_db; + use crate::ScanningStrategy; + use crate::repositories::{LibraryRepository, SeriesRepository}; + use crate::test_helpers::create_test_db; #[tokio::test] async fn test_create_and_get_metadata() { @@ -784,7 +783,7 @@ mod tests { } /// Helper that creates a library + series and returns the series UUID. - async fn make_series(db: &crate::db::Database, name: &str) -> Uuid { + async fn make_series(db: &crate::Database, name: &str) -> Uuid { let library = LibraryRepository::create( db.sea_orm_connection(), "Test Library", diff --git a/src/db/repositories/series_tracking.rs b/crates/codex-db/src/repositories/series_tracking.rs similarity index 98% rename from src/db/repositories/series_tracking.rs rename to crates/codex-db/src/repositories/series_tracking.rs index 450c03e1..e83f069b 100644 --- a/src/db/repositories/series_tracking.rs +++ b/crates/codex-db/src/repositories/series_tracking.rs @@ -13,7 +13,7 @@ use chrono::Utc; use sea_orm::{ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, Set}; use uuid::Uuid; -use crate::db::entities::series_tracking::{ +use crate::entities::series_tracking::{ self, Entity as SeriesTracking, Model as SeriesTrackingRow, }; @@ -216,9 +216,9 @@ impl SeriesTrackingRepository { #[cfg(test)] mod tests { use super::*; - use crate::db::ScanningStrategy; - use crate::db::repositories::{LibraryRepository, SeriesRepository}; - use crate::db::test_helpers::create_test_db; + use crate::ScanningStrategy; + use crate::repositories::{LibraryRepository, SeriesRepository}; + use crate::test_helpers::create_test_db; async fn make_series(db: &DatabaseConnection) -> Uuid { let library = diff --git a/src/db/repositories/settings.rs b/crates/codex-db/src/repositories/settings.rs similarity index 99% rename from src/db/repositories/settings.rs rename to crates/codex-db/src/repositories/settings.rs index 951f69d3..275bad2d 100644 --- a/src/db/repositories/settings.rs +++ b/crates/codex-db/src/repositories/settings.rs @@ -1,4 +1,4 @@ -use crate::db::entities::{ +use crate::entities::{ settings, settings::Entity as Setting, settings_history, settings_history::Entity as SettingHistory, }; @@ -291,7 +291,7 @@ impl SettingsRepository { #[cfg(test)] mod tests { use super::*; - use crate::db::test_helpers::setup_test_db; + use crate::test_helpers::setup_test_db; #[tokio::test] async fn test_get_setting() { diff --git a/src/db/repositories/sharing_tag.rs b/crates/codex-db/src/repositories/sharing_tag.rs similarity index 94% rename from src/db/repositories/sharing_tag.rs rename to crates/codex-db/src/repositories/sharing_tag.rs index 90ae135e..5ea174c6 100644 --- a/src/db/repositories/sharing_tag.rs +++ b/crates/codex-db/src/repositories/sharing_tag.rs @@ -11,7 +11,7 @@ use sea_orm::{ }; use uuid::Uuid; -use crate::db::entities::{ +use crate::entities::{ series_sharing_tags, sharing_tags, sharing_tags::Entity as SharingTags, user_sharing_tags::{self, AccessMode}, @@ -113,7 +113,7 @@ impl SharingTagRepository { /// Count series using a sharing tag pub async fn count_series_with_tag(db: &DatabaseConnection, tag_id: Uuid) -> Result<u64> { - use crate::db::entities::series_sharing_tags::Entity as SeriesSharingTags; + use crate::entities::series_sharing_tags::Entity as SeriesSharingTags; let count = SeriesSharingTags::find() .filter(series_sharing_tags::Column::SharingTagId.eq(tag_id)) @@ -125,7 +125,7 @@ impl SharingTagRepository { /// Count users with grants for a sharing tag pub async fn count_users_with_tag(db: &DatabaseConnection, tag_id: Uuid) -> Result<u64> { - use crate::db::entities::user_sharing_tags::Entity as UserSharingTags; + use crate::entities::user_sharing_tags::Entity as UserSharingTags; let count = UserSharingTags::find() .filter(user_sharing_tags::Column::SharingTagId.eq(tag_id)) @@ -142,7 +142,7 @@ impl SharingTagRepository { db: &DatabaseConnection, series_id: Uuid, ) -> Result<Vec<sharing_tags::Model>> { - use crate::db::entities::series_sharing_tags::Entity as SeriesSharingTags; + use crate::entities::series_sharing_tags::Entity as SeriesSharingTags; let tag_ids: Vec<Uuid> = SeriesSharingTags::find() .filter(series_sharing_tags::Column::SeriesId.eq(series_id)) @@ -171,7 +171,7 @@ impl SharingTagRepository { series_id: Uuid, tag_ids: Vec<Uuid>, ) -> Result<Vec<sharing_tags::Model>> { - use crate::db::entities::series_sharing_tags::Entity as SeriesSharingTags; + use crate::entities::series_sharing_tags::Entity as SeriesSharingTags; // Remove existing tag links for this series SeriesSharingTags::delete_many() @@ -208,7 +208,7 @@ impl SharingTagRepository { series_id: Uuid, tag_id: Uuid, ) -> Result<bool> { - use crate::db::entities::series_sharing_tags::Entity as SeriesSharingTags; + use crate::entities::series_sharing_tags::Entity as SeriesSharingTags; // Check if already linked let existing = SeriesSharingTags::find() @@ -236,7 +236,7 @@ impl SharingTagRepository { series_id: Uuid, tag_id: Uuid, ) -> Result<bool> { - use crate::db::entities::series_sharing_tags::Entity as SeriesSharingTags; + use crate::entities::series_sharing_tags::Entity as SeriesSharingTags; let result = SeriesSharingTags::delete_many() .filter(series_sharing_tags::Column::SeriesId.eq(series_id)) @@ -249,7 +249,7 @@ impl SharingTagRepository { /// Get all series IDs that have a specific sharing tag pub async fn get_series_with_tag(db: &DatabaseConnection, tag_id: Uuid) -> Result<Vec<Uuid>> { - use crate::db::entities::series_sharing_tags::Entity as SeriesSharingTags; + use crate::entities::series_sharing_tags::Entity as SeriesSharingTags; let series_ids: Vec<Uuid> = SeriesSharingTags::find() .filter(series_sharing_tags::Column::SharingTagId.eq(tag_id)) @@ -269,7 +269,7 @@ impl SharingTagRepository { db: &DatabaseConnection, tag_name: &str, ) -> Result<Vec<Uuid>> { - use crate::db::entities::series_sharing_tags::Entity as SeriesSharingTags; + use crate::entities::series_sharing_tags::Entity as SeriesSharingTags; let normalized = tag_name.to_lowercase().trim().to_string(); @@ -300,7 +300,7 @@ impl SharingTagRepository { db: &DatabaseConnection, substring: &str, ) -> Result<Vec<Uuid>> { - use crate::db::entities::series_sharing_tags::Entity as SeriesSharingTags; + use crate::entities::series_sharing_tags::Entity as SeriesSharingTags; let pattern = format!("%{}%", substring.to_lowercase()); @@ -336,7 +336,7 @@ impl SharingTagRepository { db: &DatabaseConnection, prefix: &str, ) -> Result<Vec<Uuid>> { - use crate::db::entities::series_sharing_tags::Entity as SeriesSharingTags; + use crate::entities::series_sharing_tags::Entity as SeriesSharingTags; let pattern = format!("{}%", prefix.to_lowercase()); @@ -370,7 +370,7 @@ impl SharingTagRepository { db: &DatabaseConnection, suffix: &str, ) -> Result<Vec<Uuid>> { - use crate::db::entities::series_sharing_tags::Entity as SeriesSharingTags; + use crate::entities::series_sharing_tags::Entity as SeriesSharingTags; let pattern = format!("%{}", suffix.to_lowercase()); @@ -407,7 +407,7 @@ impl SharingTagRepository { db: &DatabaseConnection, user_id: Uuid, ) -> Result<Vec<user_sharing_tags::Model>> { - use crate::db::entities::user_sharing_tags::Entity as UserSharingTags; + use crate::entities::user_sharing_tags::Entity as UserSharingTags; let grants = UserSharingTags::find() .filter(user_sharing_tags::Column::UserId.eq(user_id)) @@ -422,7 +422,7 @@ impl SharingTagRepository { db: &DatabaseConnection, user_id: Uuid, ) -> Result<Vec<(user_sharing_tags::Model, sharing_tags::Model)>> { - use crate::db::entities::user_sharing_tags::Entity as UserSharingTags; + use crate::entities::user_sharing_tags::Entity as UserSharingTags; let grants = UserSharingTags::find() .filter(user_sharing_tags::Column::UserId.eq(user_id)) @@ -445,7 +445,7 @@ impl SharingTagRepository { db: &DatabaseConnection, user_id: Uuid, ) -> Result<Vec<Uuid>> { - use crate::db::entities::user_sharing_tags::Entity as UserSharingTags; + use crate::entities::user_sharing_tags::Entity as UserSharingTags; let tag_ids: Vec<Uuid> = UserSharingTags::find() .filter(user_sharing_tags::Column::UserId.eq(user_id)) @@ -464,7 +464,7 @@ impl SharingTagRepository { db: &DatabaseConnection, user_id: Uuid, ) -> Result<Vec<Uuid>> { - use crate::db::entities::user_sharing_tags::Entity as UserSharingTags; + use crate::entities::user_sharing_tags::Entity as UserSharingTags; let tag_ids: Vec<Uuid> = UserSharingTags::find() .filter(user_sharing_tags::Column::UserId.eq(user_id)) @@ -485,7 +485,7 @@ impl SharingTagRepository { tag_id: Uuid, access_mode: AccessMode, ) -> Result<user_sharing_tags::Model> { - use crate::db::entities::user_sharing_tags::Entity as UserSharingTags; + use crate::entities::user_sharing_tags::Entity as UserSharingTags; // Check if grant already exists let existing = UserSharingTags::find() @@ -520,7 +520,7 @@ impl SharingTagRepository { user_id: Uuid, tag_id: Uuid, ) -> Result<bool> { - use crate::db::entities::user_sharing_tags::Entity as UserSharingTags; + use crate::entities::user_sharing_tags::Entity as UserSharingTags; let result = UserSharingTags::delete_many() .filter(user_sharing_tags::Column::UserId.eq(user_id)) @@ -534,7 +534,7 @@ impl SharingTagRepository { /// Remove all grants for a user #[allow(dead_code)] pub async fn remove_all_grants_for_user(db: &DatabaseConnection, user_id: Uuid) -> Result<u64> { - use crate::db::entities::user_sharing_tags::Entity as UserSharingTags; + use crate::entities::user_sharing_tags::Entity as UserSharingTags; let result = UserSharingTags::delete_many() .filter(user_sharing_tags::Column::UserId.eq(user_id)) @@ -547,7 +547,7 @@ impl SharingTagRepository { /// Get all users who have grants for a specific sharing tag #[allow(dead_code)] pub async fn get_users_with_tag(db: &DatabaseConnection, tag_id: Uuid) -> Result<Vec<Uuid>> { - use crate::db::entities::user_sharing_tags::Entity as UserSharingTags; + use crate::entities::user_sharing_tags::Entity as UserSharingTags; let user_ids: Vec<Uuid> = UserSharingTags::find() .filter(user_sharing_tags::Column::SharingTagId.eq(tag_id)) @@ -617,7 +617,7 @@ impl SharingTagRepository { db: &DatabaseConnection, user_id: Uuid, ) -> Result<Option<Vec<Uuid>>> { - use crate::db::entities::series_sharing_tags::Entity as SeriesSharingTags; + use crate::entities::series_sharing_tags::Entity as SeriesSharingTags; // Get user grants let allowed_tag_ids = Self::get_allowed_tag_ids_for_user(db, user_id).await?; @@ -692,7 +692,7 @@ impl SharingTagRepository { db: &DatabaseConnection, tag_ids: &[Uuid], ) -> Result<Vec<Uuid>> { - use crate::db::entities::series_sharing_tags::Entity as SeriesSharingTags; + use crate::entities::series_sharing_tags::Entity as SeriesSharingTags; if tag_ids.is_empty() { return Ok(vec![]); @@ -715,7 +715,7 @@ impl SharingTagRepository { pub async fn get_tagged_series_ids( db: &DatabaseConnection, ) -> Result<std::collections::HashSet<Uuid>> { - use crate::db::entities::series_sharing_tags::Entity as SeriesSharingTags; + use crate::entities::series_sharing_tags::Entity as SeriesSharingTags; let series_ids: std::collections::HashSet<Uuid> = SeriesSharingTags::find() .all(db) @@ -731,15 +731,15 @@ impl SharingTagRepository { #[cfg(test)] mod tests { use super::*; - use crate::db::ScanningStrategy; - use crate::db::repositories::{LibraryRepository, SeriesRepository, UserRepository}; - use crate::db::test_helpers::create_test_db; + use crate::ScanningStrategy; + use crate::repositories::{LibraryRepository, SeriesRepository, UserRepository}; + use crate::test_helpers::create_test_db; async fn create_test_user( db: &DatabaseConnection, username: &str, - ) -> crate::db::entities::users::Model { - use crate::db::entities::users; + ) -> crate::entities::users::Model { + use crate::entities::users; use chrono::Utc; let now = Utc::now(); diff --git a/src/db/repositories/tag.rs b/crates/codex-db/src/repositories/tag.rs similarity index 96% rename from src/db/repositories/tag.rs rename to crates/codex-db/src/repositories/tag.rs index 239b76d6..c6449bfc 100644 --- a/src/db/repositories/tag.rs +++ b/crates/codex-db/src/repositories/tag.rs @@ -12,7 +12,7 @@ use sea_orm::{ }; use uuid::Uuid; -use crate::db::entities::{ +use crate::entities::{ book_tags, book_tags::Entity as BookTags, series_tags, tags, tags::Entity as Tags, }; @@ -81,7 +81,7 @@ impl TagRepository { db: &DatabaseConnection, series_id: Uuid, ) -> Result<Vec<tags::Model>> { - use crate::db::entities::series_tags::Entity as SeriesTags; + use crate::entities::series_tags::Entity as SeriesTags; let tag_ids: Vec<Uuid> = SeriesTags::find() .filter(series_tags::Column::SeriesId.eq(series_id)) @@ -111,7 +111,7 @@ impl TagRepository { series_id: Uuid, tag_names: Vec<String>, ) -> Result<Vec<tags::Model>> { - use crate::db::entities::series_tags::Entity as SeriesTags; + use crate::entities::series_tags::Entity as SeriesTags; // Remove existing tag links for this series SeriesTags::delete_many() @@ -152,7 +152,7 @@ impl TagRepository { let tag = Self::find_or_create(db, tag_name).await?; // Check if already linked - use crate::db::entities::series_tags::Entity as SeriesTags; + use crate::entities::series_tags::Entity as SeriesTags; let existing = SeriesTags::find() .filter(series_tags::Column::SeriesId.eq(series_id)) .filter(series_tags::Column::TagId.eq(tag.id)) @@ -176,7 +176,7 @@ impl TagRepository { series_id: Uuid, tag_id: Uuid, ) -> Result<bool> { - use crate::db::entities::series_tags::Entity as SeriesTags; + use crate::entities::series_tags::Entity as SeriesTags; let result = SeriesTags::delete_many() .filter(series_tags::Column::SeriesId.eq(series_id)) @@ -189,7 +189,7 @@ impl TagRepository { /// Count series using a tag pub async fn count_series_with_tag(db: &DatabaseConnection, tag_id: Uuid) -> Result<u64> { - use crate::db::entities::series_tags::Entity as SeriesTags; + use crate::entities::series_tags::Entity as SeriesTags; let count = SeriesTags::find() .filter(series_tags::Column::TagId.eq(tag_id)) @@ -204,7 +204,7 @@ impl TagRepository { db: &DatabaseConnection, tag_name: &str, ) -> Result<Vec<Uuid>> { - use crate::db::entities::series_tags::Entity as SeriesTags; + use crate::entities::series_tags::Entity as SeriesTags; let normalized = tag_name.to_lowercase().trim().to_string(); @@ -266,7 +266,7 @@ impl TagRepository { db: &DatabaseConnection, substring: &str, ) -> Result<Vec<Uuid>> { - use crate::db::entities::series_tags::Entity as SeriesTags; + use crate::entities::series_tags::Entity as SeriesTags; let normalized = substring.to_lowercase(); @@ -298,7 +298,7 @@ impl TagRepository { db: &DatabaseConnection, prefix: &str, ) -> Result<Vec<Uuid>> { - use crate::db::entities::series_tags::Entity as SeriesTags; + use crate::entities::series_tags::Entity as SeriesTags; let normalized = prefix.to_lowercase(); @@ -330,7 +330,7 @@ impl TagRepository { db: &DatabaseConnection, suffix: &str, ) -> Result<Vec<Uuid>> { - use crate::db::entities::series_tags::Entity as SeriesTags; + use crate::entities::series_tags::Entity as SeriesTags; let normalized = suffix.to_lowercase(); @@ -359,7 +359,7 @@ impl TagRepository { /// Get all series IDs that have at least one tag pub async fn get_all_series_with_tags(db: &DatabaseConnection) -> Result<Vec<Uuid>> { - use crate::db::entities::series_tags::Entity as SeriesTags; + use crate::entities::series_tags::Entity as SeriesTags; let series_ids: Vec<Uuid> = SeriesTags::find() .all(db) @@ -379,7 +379,7 @@ impl TagRepository { db: &DatabaseConnection, series_ids: &[Uuid], ) -> Result<std::collections::HashMap<Uuid, Vec<tags::Model>>> { - use crate::db::entities::series_tags::Entity as SeriesTags; + use crate::entities::series_tags::Entity as SeriesTags; if series_ids.is_empty() { return Ok(std::collections::HashMap::new()); @@ -595,7 +595,7 @@ impl TagRepository { /// Delete all unused tags (tags with no series or books linked) /// Returns the names of deleted tags pub async fn delete_unused(db: &DatabaseConnection) -> Result<Vec<String>> { - use crate::db::entities::series_tags::Entity as SeriesTags; + use crate::entities::series_tags::Entity as SeriesTags; // Get all tags let all_tags = Self::list_all(db).await?; @@ -628,9 +628,9 @@ impl TagRepository { #[cfg(test)] mod tests { use super::*; - use crate::db::ScanningStrategy; - use crate::db::repositories::{LibraryRepository, SeriesRepository}; - use crate::db::test_helpers::create_test_db; + use crate::ScanningStrategy; + use crate::repositories::{LibraryRepository, SeriesRepository}; + use crate::test_helpers::create_test_db; #[tokio::test] async fn test_create_and_get_tag() { @@ -967,12 +967,12 @@ mod tests { /// Helper to create a test book for tag tests async fn create_test_book_for_tag( - db: &crate::db::Database, + db: &crate::Database, series_id: Uuid, library_id: Uuid, - ) -> crate::db::entities::books::Model { - use crate::db::entities::books; - use crate::db::repositories::BookRepository; + ) -> crate::entities::books::Model { + use crate::entities::books; + use crate::repositories::BookRepository; use chrono::Utc; let book = books::Model { diff --git a/src/db/repositories/task.rs b/crates/codex-db/src/repositories/task.rs similarity index 99% rename from src/db/repositories/task.rs rename to crates/codex-db/src/repositories/task.rs index 7a7fc904..6fa7c344 100644 --- a/src/db/repositories/task.rs +++ b/crates/codex-db/src/repositories/task.rs @@ -8,7 +8,7 @@ use sea_orm::{ use tracing::{info, warn}; use uuid::Uuid; -use crate::db::entities::{ +use crate::entities::{ book_metadata, books, libraries, prelude::*, series, series_metadata, tasks, }; use codex_models::task::{DEFAULT_MAX_RESCHEDULES, TaskStats, TaskType}; diff --git a/src/db/repositories/task_metrics.rs b/crates/codex-db/src/repositories/task_metrics.rs similarity index 99% rename from src/db/repositories/task_metrics.rs rename to crates/codex-db/src/repositories/task_metrics.rs index fb2188c5..cf5689c8 100644 --- a/src/db/repositories/task_metrics.rs +++ b/crates/codex-db/src/repositories/task_metrics.rs @@ -14,7 +14,7 @@ use std::str::FromStr; use tracing::{debug, info, warn}; use uuid::Uuid; -use crate::db::entities::{prelude::*, task_metrics}; +use crate::entities::{prelude::*, task_metrics}; /// Repository for TaskMetrics operations pub struct TaskMetricsRepository; @@ -709,7 +709,7 @@ struct AggregatedRecord { #[cfg(test)] mod tests { use super::*; - use crate::db::test_helpers::create_test_db; + use crate::test_helpers::create_test_db; #[tokio::test] async fn test_record_completion() { @@ -887,8 +887,8 @@ mod tests { #[tokio::test] async fn test_delete_by_library_id() { - use crate::db::ScanningStrategy; - use crate::db::repositories::LibraryRepository; + use crate::ScanningStrategy; + use crate::repositories::LibraryRepository; let (db, _temp_dir) = create_test_db().await; let conn = db.sea_orm_connection(); diff --git a/src/db/repositories/user.rs b/crates/codex-db/src/repositories/user.rs similarity index 98% rename from src/db/repositories/user.rs rename to crates/codex-db/src/repositories/user.rs index 8f7d578a..c5324a74 100644 --- a/src/db/repositories/user.rs +++ b/crates/codex-db/src/repositories/user.rs @@ -1,5 +1,5 @@ -use crate::db::entities::{sharing_tags, user_sharing_tags, users, users::Entity as User}; -use crate::observability::repo::db_system_str; +use crate::entities::{sharing_tags, user_sharing_tags, users, users::Entity as User}; +use crate::trace::db_system_str; use anyhow::Result; use chrono::Utc; use sea_orm::*; @@ -254,7 +254,7 @@ impl UserRepository { #[cfg(test)] mod tests { use super::*; - use crate::db::test_helpers::setup_test_db; + use crate::test_helpers::setup_test_db; #[tokio::test] async fn test_create_and_get_user() { diff --git a/src/db/repositories/user_plugin_data.rs b/crates/codex-db/src/repositories/user_plugin_data.rs similarity index 98% rename from src/db/repositories/user_plugin_data.rs rename to crates/codex-db/src/repositories/user_plugin_data.rs index d2f5a337..0ef7dad9 100644 --- a/src/db/repositories/user_plugin_data.rs +++ b/crates/codex-db/src/repositories/user_plugin_data.rs @@ -14,7 +14,7 @@ #![allow(dead_code)] -use crate::db::entities::user_plugin_data::{self, Entity as UserPluginData}; +use crate::entities::user_plugin_data::{self, Entity as UserPluginData}; use anyhow::Result; use chrono::{DateTime, Utc}; use sea_orm::*; @@ -193,10 +193,10 @@ impl UserPluginDataRepository { #[cfg(test)] mod tests { use super::*; - use crate::db::entities::plugins; - use crate::db::entities::users; - use crate::db::repositories::{PluginsRepository, UserPluginsRepository, UserRepository}; - use crate::db::test_helpers::setup_test_db; + use crate::entities::plugins; + use crate::entities::users; + use crate::repositories::{PluginsRepository, UserPluginsRepository, UserRepository}; + use crate::test_helpers::setup_test_db; use chrono::Duration; async fn create_test_user(db: &DatabaseConnection) -> users::Model { @@ -247,7 +247,7 @@ mod tests { ) -> ( users::Model, plugins::Model, - crate::db::entities::user_plugins::Model, + crate::entities::user_plugins::Model, ) { let user = create_test_user(db).await; let plugin = create_test_plugin(db).await; diff --git a/src/db/repositories/user_plugins.rs b/crates/codex-db/src/repositories/user_plugins.rs similarity index 98% rename from src/db/repositories/user_plugins.rs rename to crates/codex-db/src/repositories/user_plugins.rs index eeb2bdd6..553b48cc 100644 --- a/src/db/repositories/user_plugins.rs +++ b/crates/codex-db/src/repositories/user_plugins.rs @@ -15,7 +15,7 @@ #![allow(dead_code)] -use crate::db::entities::user_plugins::{self, Entity as UserPlugins}; +use crate::entities::user_plugins::{self, Entity as UserPlugins}; use anyhow::{Result, anyhow}; use chrono::{DateTime, Utc}; use codex_utils::credential_encryption::CredentialEncryption; @@ -461,11 +461,11 @@ impl UserPluginsRepository { #[cfg(test)] mod tests { use super::*; - use crate::db::entities::plugins; - use crate::db::entities::users; - use crate::db::repositories::PluginsRepository; - use crate::db::repositories::UserRepository; - use crate::db::test_helpers::setup_test_db; + use crate::entities::plugins; + use crate::entities::users; + use crate::repositories::PluginsRepository; + use crate::repositories::UserRepository; + use crate::test_helpers::setup_test_db; async fn create_test_user(db: &DatabaseConnection) -> users::Model { let user = users::Model { diff --git a/src/db/repositories/user_preferences.rs b/crates/codex-db/src/repositories/user_preferences.rs similarity index 99% rename from src/db/repositories/user_preferences.rs rename to crates/codex-db/src/repositories/user_preferences.rs index 24793d0b..80bf7ec2 100644 --- a/src/db/repositories/user_preferences.rs +++ b/crates/codex-db/src/repositories/user_preferences.rs @@ -6,7 +6,7 @@ #![allow(dead_code)] -use crate::db::entities::{user_preferences, user_preferences::Entity as UserPreferences}; +use crate::entities::{user_preferences, user_preferences::Entity as UserPreferences}; use anyhow::{Result, anyhow}; use chrono::Utc; use sea_orm::*; @@ -288,9 +288,9 @@ impl UserPreferencesRepository { mod tests { use super::*; - use crate::db::entities::users; - use crate::db::repositories::UserRepository; - use crate::db::test_helpers::setup_test_db; + use crate::entities::users; + use crate::repositories::UserRepository; + use crate::test_helpers::setup_test_db; use codex_utils::password; async fn create_test_user(db: &DatabaseConnection) -> users::Model { diff --git a/src/db/repositories/user_series_rating.rs b/crates/codex-db/src/repositories/user_series_rating.rs similarity index 98% rename from src/db/repositories/user_series_rating.rs rename to crates/codex-db/src/repositories/user_series_rating.rs index 27af318c..a5965750 100644 --- a/src/db/repositories/user_series_rating.rs +++ b/crates/codex-db/src/repositories/user_series_rating.rs @@ -11,7 +11,7 @@ use sea_orm::{ use std::collections::HashMap; use uuid::Uuid; -use crate::db::entities::{user_series_ratings, user_series_ratings::Entity as UserSeriesRatings}; +use crate::entities::{user_series_ratings, user_series_ratings::Entity as UserSeriesRatings}; /// Repository for user series rating operations pub struct UserSeriesRatingRepository; @@ -243,10 +243,10 @@ impl UserSeriesRatingRepository { #[cfg(test)] mod tests { use super::*; - use crate::db::ScanningStrategy; - use crate::db::entities::users; - use crate::db::repositories::{LibraryRepository, SeriesRepository, UserRepository}; - use crate::db::test_helpers::create_test_db; + use crate::ScanningStrategy; + use crate::entities::users; + use crate::repositories::{LibraryRepository, SeriesRepository, UserRepository}; + use crate::test_helpers::create_test_db; fn create_user_model(email: &str) -> users::Model { users::Model { @@ -267,7 +267,7 @@ mod tests { async fn create_test_user( db: &DatabaseConnection, email: &str, - ) -> crate::db::entities::users::Model { + ) -> crate::entities::users::Model { let user_model = create_user_model(email); UserRepository::create(db, &user_model).await.unwrap() } diff --git a/src/db/test_helpers.rs b/crates/codex-db/src/test_helpers.rs similarity index 79% rename from src/db/test_helpers.rs rename to crates/codex-db/src/test_helpers.rs index a28076ad..e718cbcf 100644 --- a/src/db/test_helpers.rs +++ b/crates/codex-db/src/test_helpers.rs @@ -1,17 +1,17 @@ -#[cfg(test)] -use crate::db::Database; -#[cfg(test)] +//! Test database helpers. +//! +//! Gated behind the `test-utils` feature so downstream crates can opt in via +//! a dev-dependency feature flag (`codex-db = { ..., features = ["test-utils"] }`) +//! without dragging the helpers into release builds. + +use crate::Database; use codex_config::{DatabaseConfig, DatabaseType, SQLiteConfig}; -#[cfg(test)] use tempfile::TempDir; /// Helper to create a test SQLite database with migrations applied /// /// This function creates a temporary SQLite database, runs all migrations, /// and returns both the database connection and the temp directory (to keep it alive). -/// -/// This function is available for unit tests within the codex crate. -#[cfg(test)] pub async fn create_test_db() -> (Database, TempDir) { use std::collections::HashMap; @@ -37,9 +37,7 @@ pub async fn create_test_db() -> (Database, TempDir) { (db, temp_dir) } -/// Simplified helper that returns the DatabaseConnection and keeps the temp dir alive -/// Available for unit tests within the codex crate -#[cfg(test)] +/// Simplified helper that returns the `DatabaseConnection` and keeps the temp dir alive. pub async fn setup_test_db() -> sea_orm::DatabaseConnection { let (db, temp_dir) = create_test_db().await; let conn = db.sea_orm_connection().clone(); diff --git a/src/observability/repo.rs b/crates/codex-db/src/trace.rs similarity index 91% rename from src/observability/repo.rs rename to crates/codex-db/src/trace.rs index e93acd6d..e10fd12b 100644 --- a/src/observability/repo.rs +++ b/crates/codex-db/src/trace.rs @@ -1,10 +1,10 @@ //! Repository instrumentation helpers. //! //! Codex's repositories sit on top of SeaORM, which does not ship a built-in -//! tracing layer. Phase 2 of the OTLP plan instruments repository methods at -//! the method boundary instead of wrapping raw SQL, so a single SeaORM call -//! shows up as one span tagged with the operation (`select`, `insert`, -//! `update`, `delete`) and a stable entity name (`book`, `series`, ...). +//! tracing layer. Repository methods are instrumented at the method boundary +//! instead of wrapping raw SQL, so a single SeaORM call shows up as one span +//! tagged with the operation (`select`, `insert`, `update`, `delete`) and a +//! stable entity name (`book`, `series`, ...). //! //! Span names follow `db.<entity>.<operation>`. Each span carries the //! [OpenTelemetry semantic-convention] attributes the `tracing-opentelemetry` @@ -18,6 +18,9 @@ //! never in the span name. This keeps span cardinality bounded by the number //! of repository methods, which is small. //! +//! Lives in the `db` module because the only inputs to `db_system_str` are +//! SeaORM types — there is no observability-side dependency. +//! //! [OpenTelemetry semantic-convention]: https://opentelemetry.io/docs/specs/semconv/database/ use sea_orm::{ConnectionTrait, DatabaseConnection, DbBackend}; @@ -122,12 +125,11 @@ mod tests { /// Demonstrates that a `#[tracing::instrument]`-decorated repository /// method emits a span with the expected name and OTel semantic-convention - /// attributes. This is the shape Phase 2 contracts: callers can rely on - /// the `db.<entity>.<operation>` naming and the `db.system`, - /// `db.operation`, `otel.kind` fields being populated. + /// attributes. Callers can rely on the `db.<entity>.<operation>` naming and + /// the `db.system`, `db.operation`, `otel.kind` fields being populated. #[tokio::test] async fn instrumented_repo_method_emits_named_span_with_semantic_conv_fields() { - use crate::db::repositories::UserRepository; + use super::super::repositories::UserRepository; use uuid::Uuid; let db = in_memory_sqlite().await; @@ -160,7 +162,7 @@ mod tests { } /// Microbench for instrumentation overhead. Not part of CI: run manually - /// with `cargo test --release -p codex -- --ignored bench_instrumentation_overhead --nocapture` + /// with `cargo test --release -p codex-db -- --ignored bench_instrumentation_overhead --nocapture` /// to get a feel for the per-call cost of `#[tracing::instrument]` under /// the two configurations that matter: /// diff --git a/src/api/docs.rs b/src/api/docs.rs index dd8ca5c7..80c31976 100644 --- a/src/api/docs.rs +++ b/src/api/docs.rs @@ -807,7 +807,7 @@ The following paths are exempt from rate limiting: v1::dto::UserSharingTagGrantDto, v1::dto::SetUserSharingTagGrantRequest, v1::dto::UserSharingTagGrantsResponse, - crate::db::entities::user_sharing_tags::AccessMode, + codex_db::entities::user_sharing_tags::AccessMode, v1::dto::BookDto, v1::dto::BookListResponse, diff --git a/src/api/extractors/auth.rs b/src/api/extractors/auth.rs index 78a13226..4edbe0b2 100644 --- a/src/api/extractors/auth.rs +++ b/src/api/extractors/auth.rs @@ -2,10 +2,10 @@ use tracing::debug; use crate::api::error::ApiError; use crate::api::permissions::{Permission, UserRole}; -use crate::db::repositories::{ApiKeyRepository, UserRepository}; use axum::http::header::COOKIE; use axum::{extract::FromRequestParts, http::request::Parts}; use chrono::{DateTime, Utc}; +use codex_db::repositories::{ApiKeyRepository, UserRepository}; use codex_utils::{jwt::JwtService, password}; use dashmap::DashMap; use sea_orm::DatabaseConnection; diff --git a/src/api/routes/komga/dto/book.rs b/src/api/routes/komga/dto/book.rs index 7ec7ed03..8813c20f 100644 --- a/src/api/routes/komga/dto/book.rs +++ b/src/api/routes/komga/dto/book.rs @@ -295,21 +295,21 @@ impl Default for KomgaBookDto { impl KomgaBookDto { /// Create a KomgaBookDto from Codex book data pub fn from_codex( - book: &crate::db::entities::books::Model, + book: &codex_db::entities::books::Model, series_title: &str, number: i32, - read_progress: Option<&crate::db::entities::read_progress::Model>, + read_progress: Option<&codex_db::entities::read_progress::Model>, ) -> Self { Self::from_codex_with_metadata(book, series_title, number, read_progress, None) } /// Create a KomgaBookDto from Codex book data with optional book metadata pub fn from_codex_with_metadata( - book: &crate::db::entities::books::Model, + book: &codex_db::entities::books::Model, series_title: &str, number: i32, - read_progress: Option<&crate::db::entities::read_progress::Model>, - book_metadata: Option<&crate::db::entities::book_metadata::Model>, + read_progress: Option<&codex_db::entities::read_progress::Model>, + book_metadata: Option<&codex_db::entities::book_metadata::Model>, ) -> Self { let media = KomgaMediaDto::from_codex( &book.format, @@ -354,9 +354,9 @@ impl KomgaBookDto { /// Build KomgaBookMetadataDto from book and optional book_metadata fn build_book_metadata( - book: &crate::db::entities::books::Model, + book: &codex_db::entities::books::Model, number: i32, - book_metadata: Option<&crate::db::entities::book_metadata::Model>, + book_metadata: Option<&codex_db::entities::book_metadata::Model>, ) -> KomgaBookMetadataDto { let Some(meta) = book_metadata else { return KomgaBookMetadataDto { diff --git a/src/api/routes/komga/handlers/books.rs b/src/api/routes/komga/handlers/books.rs index 355a360e..c210f5a0 100644 --- a/src/api/routes/komga/handlers/books.rs +++ b/src/api/routes/komga/handlers/books.rs @@ -14,11 +14,6 @@ use crate::api::{ extractors::{AuthState, FlexibleAuthContext}, permissions::Permission, }; -use crate::db::repositories::{ - BookMetadataRepository, BookQueryOptions, BookQuerySort, BookRepository, BookSortField, - ReadProgressRepository, ReadStatusFilter, ReleaseDateFilter, ReleaseDateOperator, - SeriesMetadataRepository, -}; use crate::require_permission; use axum::{ Json, @@ -28,6 +23,11 @@ use axum::{ response::Response, }; use chrono::Datelike; +use codex_db::repositories::{ + BookMetadataRepository, BookQueryOptions, BookQuerySort, BookRepository, BookSortField, + ReadProgressRepository, ReadStatusFilter, ReleaseDateFilter, ReleaseDateOperator, + SeriesMetadataRepository, +}; use serde::Deserialize; use std::sync::Arc; use tokio_util::io::ReaderStream; @@ -772,7 +772,7 @@ async fn get_series_title(state: &Arc<AuthState>, series_id: Uuid) -> Result<Str Ok(metadata.title) } else { // Fallback to series name - use crate::db::repositories::SeriesRepository; + use codex_db::repositories::SeriesRepository; if let Some(series) = SeriesRepository::get_by_id(&state.db, series_id) .await .map_err(|e| ApiError::Internal(format!("Failed to fetch series: {}", e)))? diff --git a/src/api/routes/komga/handlers/libraries.rs b/src/api/routes/komga/handlers/libraries.rs index f39c8182..f8b191b4 100644 --- a/src/api/routes/komga/handlers/libraries.rs +++ b/src/api/routes/komga/handlers/libraries.rs @@ -8,9 +8,6 @@ use crate::api::{ extractors::{AuthState, FlexibleAuthContext}, permissions::Permission, }; -use crate::db::repositories::{ - BookRepository, LibraryRepository, SeriesCoversRepository, SeriesRepository, -}; use crate::require_permission; use axum::{ Json, @@ -19,6 +16,9 @@ use axum::{ http::{StatusCode, header}, response::Response, }; +use codex_db::repositories::{ + BookRepository, LibraryRepository, SeriesCoversRepository, SeriesRepository, +}; use image::{ImageFormat, imageops::FilterType}; use std::io::Cursor; use std::sync::Arc; diff --git a/src/api/routes/komga/handlers/manifest.rs b/src/api/routes/komga/handlers/manifest.rs index 2607f65b..ece0ad5b 100644 --- a/src/api/routes/komga/handlers/manifest.rs +++ b/src/api/routes/komga/handlers/manifest.rs @@ -9,7 +9,6 @@ use crate::api::{ extractors::{AuthState, FlexibleAuthContext}, permissions::Permission, }; -use crate::db::repositories::{BookMetadataRepository, BookRepository, SeriesRepository}; use crate::require_permission; use axum::{ body::Body, @@ -17,6 +16,7 @@ use axum::{ http::{StatusCode, header}, response::Response, }; +use codex_db::repositories::{BookMetadataRepository, BookRepository, SeriesRepository}; use codex_parsers::epub::EpubParser; use std::collections::HashSet; use std::io::Read; diff --git a/src/api/routes/komga/handlers/pages.rs b/src/api/routes/komga/handlers/pages.rs index dcd365ea..d412cd04 100644 --- a/src/api/routes/komga/handlers/pages.rs +++ b/src/api/routes/komga/handlers/pages.rs @@ -10,7 +10,6 @@ use crate::api::{ extractors::{AuthState, FlexibleAuthContext}, permissions::Permission, }; -use crate::db::repositories::{BookRepository, PageRepository}; use crate::require_permission; use axum::{ Json, @@ -19,6 +18,7 @@ use axum::{ http::{StatusCode, header}, response::Response, }; +use codex_db::repositories::{BookRepository, PageRepository}; use std::sync::Arc; use uuid::Uuid; diff --git a/src/api/routes/komga/handlers/read_progress.rs b/src/api/routes/komga/handlers/read_progress.rs index fc5e98d3..779b4737 100644 --- a/src/api/routes/komga/handlers/read_progress.rs +++ b/src/api/routes/komga/handlers/read_progress.rs @@ -10,13 +10,13 @@ use crate::api::{ extractors::{AuthState, FlexibleAuthContext}, permissions::Permission, }; -use crate::db::repositories::{BookRepository, ReadProgressRepository, SeriesRepository}; use crate::require_permission; use axum::{ extract::{Path, State}, http::StatusCode, response::{IntoResponse, Response}, }; +use codex_db::repositories::{BookRepository, ReadProgressRepository, SeriesRepository}; use std::sync::Arc; use uuid::Uuid; diff --git a/src/api/routes/komga/handlers/series.rs b/src/api/routes/komga/handlers/series.rs index a5d4bc63..b90209f8 100644 --- a/src/api/routes/komga/handlers/series.rs +++ b/src/api/routes/komga/handlers/series.rs @@ -15,12 +15,6 @@ use crate::api::{ extractors::{AuthState, FlexibleAuthContext}, permissions::Permission, }; -use crate::db::repositories::{ - AlternateTitleRepository, BookMetadataRepository, BookQueryOptions, BookQuerySort, - BookRepository, BookSortField, ExternalLinkRepository, GenreRepository, ReadProgressRepository, - SeriesCoversRepository, SeriesMetadataRepository, SeriesQueryOptions, SeriesQuerySort, - SeriesRepository, SeriesSortFieldRepo, TagRepository, -}; use crate::require_permission; use axum::{ Json, @@ -29,6 +23,12 @@ use axum::{ http::{StatusCode, header}, response::Response, }; +use codex_db::repositories::{ + AlternateTitleRepository, BookMetadataRepository, BookQueryOptions, BookQuerySort, + BookRepository, BookSortField, ExternalLinkRepository, GenreRepository, ReadProgressRepository, + SeriesCoversRepository, SeriesMetadataRepository, SeriesQueryOptions, SeriesQuerySort, + SeriesRepository, SeriesSortFieldRepo, TagRepository, +}; use serde::Deserialize; use std::sync::Arc; use tokio::fs; @@ -747,7 +747,7 @@ pub async fn get_series_books( /// Build a KomgaSeriesDto from a series entity async fn build_series_dto( state: &Arc<AuthState>, - series: &crate::db::entities::series::Model, + series: &codex_db::entities::series::Model, user_id: Option<Uuid>, ) -> Result<KomgaSeriesDto, ApiError> { // Get metadata diff --git a/src/api/routes/komga/handlers/stubs.rs b/src/api/routes/komga/handlers/stubs.rs index 93739f9b..6c569728 100644 --- a/src/api/routes/komga/handlers/stubs.rs +++ b/src/api/routes/komga/handlers/stubs.rs @@ -11,12 +11,12 @@ use crate::api::{ extractors::{AuthState, FlexibleAuthContext}, permissions::Permission, }; -use crate::db::repositories::{GenreRepository, TagRepository}; use crate::require_permission; use axum::{ Json, extract::{Query, State}, }; +use codex_db::repositories::{GenreRepository, TagRepository}; use std::sync::Arc; /// List collections (stub - always returns empty) diff --git a/src/api/routes/koreader/handlers/sync.rs b/src/api/routes/koreader/handlers/sync.rs index 6e1c1753..8ce56e3d 100644 --- a/src/api/routes/koreader/handlers/sync.rs +++ b/src/api/routes/koreader/handlers/sync.rs @@ -8,10 +8,10 @@ use crate::api::error::ApiError; use crate::api::extractors::{AuthContext, AuthState}; use crate::api::permissions::Permission; use crate::api::routes::koreader::dto::progress::DocumentProgressDto; -use crate::db::entities::books; -use crate::db::repositories::{BookRepository, ReadProgressRepository}; use axum::Json; use axum::extract::{Path, State}; +use codex_db::entities::books; +use codex_db::repositories::{BookRepository, ReadProgressRepository}; use codex_parsers::EpubPosition; use std::sync::Arc; diff --git a/src/api/routes/opds/handlers/catalog.rs b/src/api/routes/opds/handlers/catalog.rs index 63383884..f16ed341 100644 --- a/src/api/routes/opds/handlers/catalog.rs +++ b/src/api/routes/opds/handlers/catalog.rs @@ -4,10 +4,6 @@ use crate::api::{ extractors::{AuthContext, AuthState}, permissions::Permission, }; -use crate::db::repositories::{ - BookMetadataRepository, BookRepository, LibraryRepository, ReadProgressRepository, - SeriesMetadataRepository, SeriesRepository, SettingsRepository, -}; use crate::require_permission; use axum::{ extract::{Path, Query, State}, @@ -15,6 +11,10 @@ use axum::{ response::{IntoResponse, Response}, }; use chrono::Utc; +use codex_db::repositories::{ + BookMetadataRepository, BookRepository, LibraryRepository, ReadProgressRepository, + SeriesMetadataRepository, SeriesRepository, SettingsRepository, +}; use serde::Deserialize; use std::sync::Arc; use uuid::Uuid; diff --git a/src/api/routes/opds/handlers/pse.rs b/src/api/routes/opds/handlers/pse.rs index e52016e6..df51110c 100644 --- a/src/api/routes/opds/handlers/pse.rs +++ b/src/api/routes/opds/handlers/pse.rs @@ -5,7 +5,6 @@ use crate::api::{ extractors::{AuthContext, AuthState, FlexibleAuthContext}, permissions::Permission, }; -use crate::db::repositories::{BookMetadataRepository, BookRepository, SettingsRepository}; use crate::require_permission; use axum::{ extract::{Path, State}, @@ -13,6 +12,7 @@ use axum::{ response::{IntoResponse, Response}, }; use chrono::Utc; +use codex_db::repositories::{BookMetadataRepository, BookRepository, SettingsRepository}; use std::sync::Arc; use uuid::Uuid; diff --git a/src/api/routes/opds/handlers/search.rs b/src/api/routes/opds/handlers/search.rs index 3640581e..624d8fef 100644 --- a/src/api/routes/opds/handlers/search.rs +++ b/src/api/routes/opds/handlers/search.rs @@ -4,10 +4,6 @@ use crate::api::{ extractors::{AuthContext, AuthState}, permissions::Permission, }; -use crate::db::repositories::{ - BookMetadataRepository, BookRepository, ReadProgressRepository, SeriesMetadataRepository, - SeriesRepository, SettingsRepository, -}; use crate::require_permission; use axum::{ extract::{Query, State}, @@ -15,6 +11,10 @@ use axum::{ response::{IntoResponse, Response}, }; use chrono::Utc; +use codex_db::repositories::{ + BookMetadataRepository, BookRepository, ReadProgressRepository, SeriesMetadataRepository, + SeriesRepository, SettingsRepository, +}; use serde::Deserialize; use std::sync::Arc; diff --git a/src/api/routes/opds2/handlers/catalog.rs b/src/api/routes/opds2/handlers/catalog.rs index b598eed1..25f55cc0 100644 --- a/src/api/routes/opds2/handlers/catalog.rs +++ b/src/api/routes/opds2/handlers/catalog.rs @@ -8,10 +8,6 @@ use crate::api::{ permissions::Permission, routes::opds::handlers::OpdsPaginationParams, }; -use crate::db::repositories::{ - BookMetadataRepository, BookRepository, LibraryRepository, ReadProgressRepository, - SeriesMetadataRepository, SeriesRepository, SettingsRepository, -}; use crate::require_permission; use axum::{ Json, @@ -19,6 +15,10 @@ use axum::{ http::{StatusCode, header}, response::{IntoResponse, Response}, }; +use codex_db::repositories::{ + BookMetadataRepository, BookRepository, LibraryRepository, ReadProgressRepository, + SeriesMetadataRepository, SeriesRepository, SettingsRepository, +}; use std::sync::Arc; use uuid::Uuid; diff --git a/src/api/routes/opds2/handlers/search.rs b/src/api/routes/opds2/handlers/search.rs index 4179d1ad..f73aeead 100644 --- a/src/api/routes/opds2/handlers/search.rs +++ b/src/api/routes/opds2/handlers/search.rs @@ -7,12 +7,12 @@ use crate::api::{ extractors::{AuthContext, AuthState}, permissions::Permission, }; -use crate::db::repositories::{ +use crate::require_permission; +use axum::extract::{Query, State}; +use codex_db::repositories::{ BookMetadataRepository, BookRepository, ReadProgressRepository, SeriesMetadataRepository, SeriesRepository, }; -use crate::require_permission; -use axum::extract::{Query, State}; use serde::Deserialize; use std::sync::Arc; diff --git a/src/api/routes/v1/dto/book.rs b/src/api/routes/v1/dto/book.rs index a45fe4cd..f5364710 100644 --- a/src/api/routes/v1/dto/book.rs +++ b/src/api/routes/v1/dto/book.rs @@ -8,7 +8,7 @@ use super::common::PaginatedResponse; use super::read_progress::ReadProgressResponse; // Re-export BookType from entity for API use -pub use crate::db::entities::book_metadata::BookType; +pub use codex_db::entities::book_metadata::BookType; // ============================================================================= // Book Type DTO (API representation) @@ -209,8 +209,8 @@ pub struct BookExternalIdDto { pub updated_at: DateTime<Utc>, } -impl From<crate::db::entities::book_external_ids::Model> for BookExternalIdDto { - fn from(model: crate::db::entities::book_external_ids::Model) -> Self { +impl From<codex_db::entities::book_external_ids::Model> for BookExternalIdDto { + fn from(model: codex_db::entities::book_external_ids::Model) -> Self { Self { id: model.id, book_id: model.book_id, @@ -272,8 +272,8 @@ pub struct BookCoverDto { pub updated_at: DateTime<Utc>, } -impl From<crate::db::entities::book_covers::Model> for BookCoverDto { - fn from(model: crate::db::entities::book_covers::Model) -> Self { +impl From<codex_db::entities::book_covers::Model> for BookCoverDto { + fn from(model: codex_db::entities::book_covers::Model) -> Self { Self { id: model.id, book_id: model.book_id, @@ -356,8 +356,8 @@ pub struct BookExternalLinkDto { pub updated_at: DateTime<Utc>, } -impl From<crate::db::entities::book_external_links::Model> for BookExternalLinkDto { - fn from(model: crate::db::entities::book_external_links::Model) -> Self { +impl From<codex_db::entities::book_external_links::Model> for BookExternalLinkDto { + fn from(model: codex_db::entities::book_external_links::Model) -> Self { Self { id: model.id, book_id: model.book_id, @@ -1628,9 +1628,9 @@ pub enum BookErrorTypeDto { Other, } -impl From<crate::db::entities::book_error::BookErrorType> for BookErrorTypeDto { - fn from(t: crate::db::entities::book_error::BookErrorType) -> Self { - use crate::db::entities::book_error::BookErrorType; +impl From<codex_db::entities::book_error::BookErrorType> for BookErrorTypeDto { + fn from(t: codex_db::entities::book_error::BookErrorType) -> Self { + use codex_db::entities::book_error::BookErrorType; match t { BookErrorType::FormatDetection => BookErrorTypeDto::FormatDetection, BookErrorType::Parser => BookErrorTypeDto::Parser, @@ -1644,9 +1644,9 @@ impl From<crate::db::entities::book_error::BookErrorType> for BookErrorTypeDto { } } -impl From<BookErrorTypeDto> for crate::db::entities::book_error::BookErrorType { +impl From<BookErrorTypeDto> for codex_db::entities::book_error::BookErrorType { fn from(t: BookErrorTypeDto) -> Self { - use crate::db::entities::book_error::BookErrorType; + use codex_db::entities::book_error::BookErrorType; match t { BookErrorTypeDto::FormatDetection => BookErrorType::FormatDetection, BookErrorTypeDto::Parser => BookErrorType::Parser, diff --git a/src/api/routes/v1/dto/filter_preset.rs b/src/api/routes/v1/dto/filter_preset.rs index bec13b80..e137be67 100644 --- a/src/api/routes/v1/dto/filter_preset.rs +++ b/src/api/routes/v1/dto/filter_preset.rs @@ -85,7 +85,7 @@ pub struct FilterPresetDto { } impl FilterPresetDto { - pub fn from_model(m: &crate::db::entities::filter_presets::Model) -> Self { + pub fn from_model(m: &codex_db::entities::filter_presets::Model) -> Self { Self { id: m.id, name: m.name.clone(), diff --git a/src/api/routes/v1/dto/plugins.rs b/src/api/routes/v1/dto/plugins.rs index 32a4c568..dba7d7ee 100644 --- a/src/api/routes/v1/dto/plugins.rs +++ b/src/api/routes/v1/dto/plugins.rs @@ -8,12 +8,12 @@ use serde::{Deserialize, Serialize}; use utoipa::ToSchema; use uuid::Uuid; -use crate::db::entities::plugin_failures; -use crate::db::entities::plugins::{self, InternalPluginConfig, PluginPermission}; -use crate::db::repositories::PluginsRepository; use crate::services::plugin::protocol::{ CredentialField, MetadataContentType, PluginCapabilities, PluginScope, }; +use codex_db::entities::plugin_failures; +use codex_db::entities::plugins::{self, InternalPluginConfig, PluginPermission}; +use codex_db::repositories::PluginsRepository; use super::common::deserialize_optional_nullable; diff --git a/src/api/routes/v1/dto/read_progress.rs b/src/api/routes/v1/dto/read_progress.rs index 921174ae..19af3240 100644 --- a/src/api/routes/v1/dto/read_progress.rs +++ b/src/api/routes/v1/dto/read_progress.rs @@ -67,8 +67,8 @@ pub struct ReadProgressResponse { pub completed_at: Option<DateTime<Utc>>, } -impl From<crate::db::entities::read_progress::Model> for ReadProgressResponse { - fn from(model: crate::db::entities::read_progress::Model) -> Self { +impl From<codex_db::entities::read_progress::Model> for ReadProgressResponse { + fn from(model: codex_db::entities::read_progress::Model) -> Self { Self { id: model.id, user_id: model.user_id, diff --git a/src/api/routes/v1/dto/release.rs b/src/api/routes/v1/dto/release.rs index 1eb9bf8f..071bbd3a 100644 --- a/src/api/routes/v1/dto/release.rs +++ b/src/api/routes/v1/dto/release.rs @@ -15,7 +15,7 @@ use serde::{Deserialize, Serialize}; use utoipa::ToSchema; use uuid::Uuid; -use crate::db::entities::{release_ledger, release_sources}; +use codex_db::entities::{release_ledger, release_sources}; // ============================================================================= // Release ledger DTOs diff --git a/src/api/routes/v1/dto/series.rs b/src/api/routes/v1/dto/series.rs index e7808f65..a7424932 100644 --- a/src/api/routes/v1/dto/series.rs +++ b/src/api/routes/v1/dto/series.rs @@ -796,8 +796,8 @@ pub struct SeriesExternalIdDto { pub updated_at: DateTime<Utc>, } -impl From<crate::db::entities::series_external_ids::Model> for SeriesExternalIdDto { - fn from(model: crate::db::entities::series_external_ids::Model) -> Self { +impl From<codex_db::entities::series_external_ids::Model> for SeriesExternalIdDto { + fn from(model: codex_db::entities::series_external_ids::Model) -> Self { Self { id: model.id, series_id: model.series_id, diff --git a/src/api/routes/v1/dto/series_export.rs b/src/api/routes/v1/dto/series_export.rs index 793c8b92..91e45120 100644 --- a/src/api/routes/v1/dto/series_export.rs +++ b/src/api/routes/v1/dto/series_export.rs @@ -47,7 +47,7 @@ pub struct SeriesExportDto { } impl SeriesExportDto { - pub fn from_model(m: &crate::db::entities::series_exports::Model) -> Self { + pub fn from_model(m: &codex_db::entities::series_exports::Model) -> Self { let library_ids: Vec<Uuid> = serde_json::from_value(m.library_ids.clone()).unwrap_or_default(); let fields: Vec<String> = serde_json::from_value(m.fields.clone()).unwrap_or_default(); diff --git a/src/api/routes/v1/dto/sharing_tag.rs b/src/api/routes/v1/dto/sharing_tag.rs index 10697469..0d5da9a7 100644 --- a/src/api/routes/v1/dto/sharing_tag.rs +++ b/src/api/routes/v1/dto/sharing_tag.rs @@ -5,7 +5,7 @@ use serde::{Deserialize, Serialize}; use utoipa::ToSchema; use uuid::Uuid; -use crate::db::entities::user_sharing_tags::AccessMode; +use codex_db::entities::user_sharing_tags::AccessMode; /// Sharing tag data transfer object #[derive(Debug, Serialize, ToSchema)] @@ -165,8 +165,8 @@ pub struct UserSharingTagGrantsResponse { // Conversion implementations -impl From<crate::db::entities::sharing_tags::Model> for SharingTagSummaryDto { - fn from(model: crate::db::entities::sharing_tags::Model) -> Self { +impl From<codex_db::entities::sharing_tags::Model> for SharingTagSummaryDto { + fn from(model: codex_db::entities::sharing_tags::Model) -> Self { Self { id: model.id, name: model.name, @@ -178,7 +178,7 @@ impl From<crate::db::entities::sharing_tags::Model> for SharingTagSummaryDto { impl SharingTagDto { /// Create a DTO from model with counts pub fn from_model_with_counts( - model: crate::db::entities::sharing_tags::Model, + model: codex_db::entities::sharing_tags::Model, series_count: u64, user_count: u64, ) -> Self { @@ -197,8 +197,8 @@ impl SharingTagDto { impl UserSharingTagGrantDto { /// Create a DTO from grant model and sharing tag model pub fn from_models( - grant: crate::db::entities::user_sharing_tags::Model, - tag: crate::db::entities::sharing_tags::Model, + grant: codex_db::entities::user_sharing_tags::Model, + tag: codex_db::entities::sharing_tags::Model, ) -> Self { Self { id: grant.id, diff --git a/src/api/routes/v1/dto/tracking.rs b/src/api/routes/v1/dto/tracking.rs index 94140a65..18022aae 100644 --- a/src/api/routes/v1/dto/tracking.rs +++ b/src/api/routes/v1/dto/tracking.rs @@ -9,7 +9,7 @@ use serde::{Deserialize, Serialize}; use utoipa::ToSchema; use uuid::Uuid; -use crate::db::entities::{series_aliases, series_tracking}; +use codex_db::entities::{series_aliases, series_tracking}; // ============================================================================= // Tracking config DTOs diff --git a/src/api/routes/v1/dto/user_plugins.rs b/src/api/routes/v1/dto/user_plugins.rs index db1de5c2..679cdc3e 100644 --- a/src/api/routes/v1/dto/user_plugins.rs +++ b/src/api/routes/v1/dto/user_plugins.rs @@ -245,8 +245,8 @@ pub struct UserPluginTaskDto { pub completed_at: Option<DateTime<Utc>>, } -impl From<crate::db::entities::tasks::Model> for UserPluginTaskDto { - fn from(task: crate::db::entities::tasks::Model) -> Self { +impl From<codex_db::entities::tasks::Model> for UserPluginTaskDto { + fn from(task: codex_db::entities::tasks::Model) -> Self { Self { task_id: task.id, task_type: task.task_type, diff --git a/src/api/routes/v1/dto/user_preferences.rs b/src/api/routes/v1/dto/user_preferences.rs index f2e2e4f8..f5ee50a1 100644 --- a/src/api/routes/v1/dto/user_preferences.rs +++ b/src/api/routes/v1/dto/user_preferences.rs @@ -5,8 +5,8 @@ use serde::{Deserialize, Serialize}; use std::collections::HashMap; use utoipa::ToSchema; -use crate::db::entities::user_preferences; -use crate::db::repositories::UserPreferencesRepository; +use codex_db::entities::user_preferences; +use codex_db::repositories::UserPreferencesRepository; /// A single user preference #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] diff --git a/src/api/routes/v1/handlers/api_keys.rs b/src/api/routes/v1/handlers/api_keys.rs index 736c9091..63c8bb62 100644 --- a/src/api/routes/v1/handlers/api_keys.rs +++ b/src/api/routes/v1/handlers/api_keys.rs @@ -10,8 +10,6 @@ use crate::api::{ extractors::{AuthContext, AuthState}, permissions::{Permission, serialize_permissions}, }; -use crate::db::entities::api_keys; -use crate::db::repositories::ApiKeyRepository; use axum::{ Json, extract::{Path, Query, State}, @@ -19,6 +17,8 @@ use axum::{ response::Response, }; use chrono::Utc; +use codex_db::entities::api_keys; +use codex_db::repositories::ApiKeyRepository; use codex_utils::password; use rand::RngExt; use sea_orm::ActiveModelTrait; diff --git a/src/api/routes/v1/handlers/auth.rs b/src/api/routes/v1/handlers/auth.rs index d4255343..3a4dc230 100644 --- a/src/api/routes/v1/handlers/auth.rs +++ b/src/api/routes/v1/handlers/auth.rs @@ -8,10 +8,6 @@ use crate::api::{ extractors::{AuthContext, AuthState, ClientInfo, FlexibleAuthContext}, permissions::UserRole, // Used for creating users with default role }; -use crate::db::{ - entities::users, - repositories::{EmailVerificationTokenRepository, SettingsRepository, UserRepository}, -}; use crate::services::RefreshTokenError; use axum::{ Json, @@ -20,6 +16,10 @@ use axum::{ response::{IntoResponse, Response}, }; use chrono::Utc; +use codex_db::{ + entities::users, + repositories::{EmailVerificationTokenRepository, SettingsRepository, UserRepository}, +}; use codex_utils::password; use sea_orm::ActiveModelTrait; use sea_orm::Set; @@ -400,7 +400,7 @@ pub async fn register( Json(request): Json<RegisterRequest>, ) -> Result<Response, ApiError> { // Check if registration is enabled (from database settings) - use crate::db::repositories::SettingsRepository; + use codex_db::repositories::SettingsRepository; let registration_enabled = SettingsRepository::get_value::<bool>(&state.db, "auth.registration_enabled") .await diff --git a/src/api/routes/v1/handlers/books.rs b/src/api/routes/v1/handlers/books.rs index 471c7744..01a60c46 100644 --- a/src/api/routes/v1/handlers/books.rs +++ b/src/api/routes/v1/handlers/books.rs @@ -18,10 +18,6 @@ use crate::api::{ extractors::{AuthContext, AuthState, ContentFilter, FlexibleAuthContext}, permissions::Permission, }; -use crate::db::repositories::{ - BookMetadataRepository, BookRepository, GenreRepository, LibraryRepository, PageRepository, - ReadProgressRepository, SeriesMetadataRepository, TagRepository, -}; use crate::require_permission; use crate::services::FilterService; use axum::{ @@ -31,6 +27,10 @@ use axum::{ http::{StatusCode, header}, response::{IntoResponse, Response}, }; +use codex_db::repositories::{ + BookMetadataRepository, BookRepository, GenreRepository, LibraryRepository, PageRepository, + ReadProgressRepository, SeriesMetadataRepository, TagRepository, +}; use codex_utils::{ json_merge_patch, normalize_for_search, parse_custom_metadata, serialize_custom_metadata, validate_custom_metadata_size, @@ -180,7 +180,7 @@ pub struct BookGetQuery { pub async fn books_to_dtos( db: &sea_orm::DatabaseConnection, user_id: Uuid, - books: Vec<crate::db::entities::books::Model>, + books: Vec<codex_db::entities::books::Model>, ) -> Result<Vec<BookDto>, ApiError> { if books.is_empty() { return Ok(Vec::new()); @@ -316,7 +316,7 @@ pub async fn books_to_dtos( pub async fn books_to_full_dtos_batched( db: &sea_orm::DatabaseConnection, user_id: Uuid, - books: Vec<crate::db::entities::books::Model>, + books: Vec<codex_db::entities::books::Model>, ) -> Result<Vec<FullBookResponse>, ApiError> { use chrono::Utc; @@ -883,7 +883,7 @@ pub async fn list_books_filtered( None }; - let fuzzy_enabled = crate::db::repositories::SettingsRepository::get_value::<bool>( + let fuzzy_enabled = codex_db::repositories::SettingsRepository::get_value::<bool>( &state.db, "search.fuzzy.enabled", ) @@ -2134,8 +2134,8 @@ pub async fn get_book_file( use crate::api::routes::v1::dto::{ BookMetadataResponse, PatchBookMetadataRequest, ReplaceBookMetadataRequest, }; -use crate::db::entities::book_metadata; use chrono::Utc; +use codex_db::entities::book_metadata; use codex_events::{EntityChangeEvent, EntityEvent}; use sea_orm::{ActiveModelTrait, Set}; @@ -3524,7 +3524,7 @@ pub async fn upload_book_cover( .ok() .flatten() { - let mut active: crate::db::entities::book_metadata::ActiveModel = meta.into(); + let mut active: codex_db::entities::book_metadata::ActiveModel = meta.into(); active.cover_lock = sea_orm::Set(true); active.updated_at = sea_orm::Set(Utc::now()); let _ = active.update(&state.db).await; @@ -3552,7 +3552,7 @@ pub async fn upload_book_cover( use super::super::dto::{ BookCoverListResponse, BookExternalIdListResponse, CreateBookExternalIdRequest, }; -use crate::db::repositories::{BookCoversRepository, BookExternalIdRepository}; +use codex_db::repositories::{BookCoversRepository, BookExternalIdRepository}; /// List all external IDs for a book #[utoipa::path( @@ -3731,7 +3731,7 @@ pub async fn delete_book_external_id( use super::super::dto::{ BookExternalLinkDto, BookExternalLinkListResponse, CreateBookExternalLinkRequest, }; -use crate::db::repositories::BookExternalLinkRepository; +use codex_db::repositories::BookExternalLinkRepository; /// List all external links for a book #[utoipa::path( @@ -3985,7 +3985,7 @@ pub async fn select_book_cover( .ok() .flatten() { - let mut active: crate::db::entities::book_metadata::ActiveModel = meta.into(); + let mut active: codex_db::entities::book_metadata::ActiveModel = meta.into(); active.cover_lock = sea_orm::Set(true); active.updated_at = sea_orm::Set(Utc::now()); let _ = active.update(&state.db).await; @@ -4299,9 +4299,9 @@ use super::super::dto::{ BookErrorDto, BookErrorTypeDto, BookWithErrorsDto, BooksWithErrorsResponse, ErrorGroupDto, RetryAllErrorsRequest, RetryBookErrorsRequest, RetryErrorsResponse, }; -use crate::db::entities::book_error::{BookErrorType, parse_analysis_errors}; -use crate::db::repositories::TaskRepository; use crate::tasks::types::TaskType; +use codex_db::entities::book_error::{BookErrorType, parse_analysis_errors}; +use codex_db::repositories::TaskRepository; /// Query parameters for listing books with analysis errors #[derive(Debug, Deserialize, utoipa::IntoParams)] diff --git a/src/api/routes/v1/handlers/bulk.rs b/src/api/routes/v1/handlers/bulk.rs index 3fd1a5b4..5fba8048 100644 --- a/src/api/routes/v1/handlers/bulk.rs +++ b/src/api/routes/v1/handlers/bulk.rs @@ -10,16 +10,16 @@ use super::super::dto::{ BulkReprocessSeriesTitlesRequest, BulkSeriesRequest, BulkTaskResponse, MarkReadResponse, }; use crate::api::{AppState, error::ApiError, extractors::AuthContext, permissions::Permission}; -use crate::db::repositories::{ +use crate::require_permission; +use crate::tasks::types::TaskType; +use axum::{Json, extract::State}; +use chrono::Utc; +use codex_db::repositories::{ AlternateTitleRepository, BookRepository, ExternalLinkRepository, ExternalRatingRepository, GenreRepository, ReadProgressRepository, SeriesCoversRepository, SeriesExternalIdRepository, SeriesMetadataRepository, SeriesRepository, SharingTagRepository, TagRepository, TaskRepository, }; -use crate::require_permission; -use crate::tasks::types::TaskType; -use axum::{Json, extract::State}; -use chrono::Utc; use codex_events::{EntityChangeEvent, EntityEvent}; use std::sync::Arc; use uuid::Uuid; @@ -895,7 +895,7 @@ pub async fn bulk_reset_series_metadata( } // Delete metadata sources - use crate::db::entities::metadata_sources; + use codex_db::entities::metadata_sources; use sea_orm::{ColumnTrait, EntityTrait, QueryFilter}; if let Err(e) = metadata_sources::Entity::delete_many() .filter(metadata_sources::Column::SeriesId.eq(*series_id)) diff --git a/src/api/routes/v1/handlers/bulk_metadata.rs b/src/api/routes/v1/handlers/bulk_metadata.rs index 3e445ae5..b464ab0c 100644 --- a/src/api/routes/v1/handlers/bulk_metadata.rs +++ b/src/api/routes/v1/handlers/bulk_metadata.rs @@ -5,14 +5,14 @@ use super::super::dto::bulk_metadata::*; use crate::api::{AppState, error::ApiError, extractors::AuthContext, permissions::Permission}; -use crate::db::entities::{book_metadata, series_metadata}; -use crate::db::repositories::{ - BookMetadataRepository, BookRepository, GenreRepository, SeriesMetadataRepository, - SeriesRepository, TagRepository, -}; use crate::require_permission; use axum::{Json, extract::State}; use chrono::Utc; +use codex_db::entities::{book_metadata, series_metadata}; +use codex_db::repositories::{ + BookMetadataRepository, BookRepository, GenreRepository, SeriesMetadataRepository, + SeriesRepository, TagRepository, +}; use codex_events::{EntityChangeEvent, EntityEvent}; use codex_utils::{ json_merge_patch, parse_custom_metadata, serialize_custom_metadata, diff --git a/src/api/routes/v1/handlers/cleanup.rs b/src/api/routes/v1/handlers/cleanup.rs index 9384fd3a..1d6ebcc9 100644 --- a/src/api/routes/v1/handlers/cleanup.rs +++ b/src/api/routes/v1/handlers/cleanup.rs @@ -17,10 +17,10 @@ use crate::api::{ extractors::{AppState, AuthContext}, permissions::Permission, }; -use crate::db::repositories::{BookRepository, SeriesRepository, TaskRepository}; use crate::require_permission; use crate::services::file_cleanup::OrphanedFileType; use crate::tasks::types::TaskType; +use codex_db::repositories::{BookRepository, SeriesRepository, TaskRepository}; /// Get statistics about orphaned files /// diff --git a/src/api/routes/v1/handlers/duplicates.rs b/src/api/routes/v1/handlers/duplicates.rs index 59b063e6..8e04a355 100644 --- a/src/api/routes/v1/handlers/duplicates.rs +++ b/src/api/routes/v1/handlers/duplicates.rs @@ -17,11 +17,11 @@ use super::super::dto::{ TriggerDuplicateScanResponse, }; use crate::api::{AppState, error::ApiError, extractors::AuthContext, permissions::Permission}; -use crate::db::entities::series_duplicates::{MATCH_TYPE_EXTERNAL_ID, MATCH_TYPE_TITLE}; -use crate::db::repositories::{ +use crate::tasks::types::TaskType; +use codex_db::entities::series_duplicates::{MATCH_TYPE_EXTERNAL_ID, MATCH_TYPE_TITLE}; +use codex_db::repositories::{ BookDuplicatesRepository, SeriesDuplicatesRepository, TaskRepository, }; -use crate::tasks::types::TaskType; /// List all duplicate book groups /// @@ -99,7 +99,7 @@ pub async fn trigger_duplicate_scan( auth.require_permission(&Permission::BooksWrite)?; // Check if there's already a pending/processing duplicate scan - use crate::db::entities::{prelude::*, tasks}; + use codex_db::entities::{prelude::*, tasks}; use sea_orm::{ColumnTrait, EntityTrait, QueryFilter}; let existing_scan = Tasks::find() @@ -157,7 +157,7 @@ pub async fn delete_duplicate_group( auth.require_permission(&Permission::BooksWrite)?; // Check if the duplicate group exists - use crate::db::entities::book_duplicates::Entity as BookDuplicates; + use codex_db::entities::book_duplicates::Entity as BookDuplicates; use sea_orm::EntityTrait; let exists = BookDuplicates::find_by_id(duplicate_id) @@ -393,7 +393,7 @@ pub async fn delete_series_duplicate_group( ) -> Result<StatusCode, ApiError> { auth.require_permission(&Permission::SeriesWrite)?; - use crate::db::entities::prelude::SeriesDuplicates; + use codex_db::entities::prelude::SeriesDuplicates; use sea_orm::EntityTrait; let exists = SeriesDuplicates::find_by_id(duplicate_id) diff --git a/src/api/routes/v1/handlers/filter_presets.rs b/src/api/routes/v1/handlers/filter_presets.rs index 09ae31db..80d5ad3a 100644 --- a/src/api/routes/v1/handlers/filter_presets.rs +++ b/src/api/routes/v1/handlers/filter_presets.rs @@ -14,7 +14,7 @@ use uuid::Uuid; use crate::api::error::ApiError; use crate::api::extractors::auth::{AppState, AuthContext}; -use crate::db::repositories::{ +use codex_db::repositories::{ FilterPresetRepository, ListFilterPresetsQuery as RepoListQuery, UpdateFilterPreset, }; diff --git a/src/api/routes/v1/handlers/libraries.rs b/src/api/routes/v1/handlers/libraries.rs index 994b1e97..3b24e8c9 100644 --- a/src/api/routes/v1/handlers/libraries.rs +++ b/src/api/routes/v1/handlers/libraries.rs @@ -11,8 +11,6 @@ use crate::api::{ extractors::{AuthContext, AuthState}, permissions::Permission, }; -use crate::db::entities::libraries; -use crate::db::repositories::{CreateLibraryParams, LibraryRepository}; use crate::require_permission; use crate::scanner::strategies::create_strategy; use axum::{ @@ -21,6 +19,8 @@ use axum::{ response::Response, }; use chrono::Utc; +use codex_db::entities::libraries; +use codex_db::repositories::{CreateLibraryParams, LibraryRepository}; use codex_models::{BookStrategy, NumberStrategy, SeriesStrategy}; use sea_orm::DatabaseConnection; use std::sync::Arc; @@ -29,10 +29,10 @@ use uuid::Uuid; /// Helper function to convert a library entity to a DTO async fn library_to_dto(db: &DatabaseConnection, library: libraries::Model) -> LibraryDto { // Get counts - let book_count = crate::db::repositories::BookRepository::count_by_library(db, library.id) + let book_count = codex_db::repositories::BookRepository::count_by_library(db, library.id) .await .ok(); - let series_count = crate::db::repositories::SeriesRepository::count_by_library(db, library.id) + let series_count = codex_db::repositories::SeriesRepository::count_by_library(db, library.id) .await .ok(); @@ -309,7 +309,7 @@ pub async fn create_library( mode: "normal".to_string(), }; - crate::db::repositories::TaskRepository::enqueue(&state.db, task_type, None) + codex_db::repositories::TaskRepository::enqueue(&state.db, task_type, None) .await .map_err(|e| ApiError::Internal(format!("Failed to trigger auto-scan: {}", e)))?; } @@ -581,7 +581,7 @@ pub async fn purge_deleted_books( .ok_or_else(|| ApiError::NotFound("Library not found".to_string()))?; // Purge deleted books - let count = crate::db::repositories::BookRepository::purge_deleted_in_library( + let count = codex_db::repositories::BookRepository::purge_deleted_in_library( &state.db, library_id, Some(&state.event_broadcaster), diff --git a/src/api/routes/v1/handlers/library_jobs.rs b/src/api/routes/v1/handlers/library_jobs.rs index a823e348..f34a9d6b 100644 --- a/src/api/routes/v1/handlers/library_jobs.rs +++ b/src/api/routes/v1/handlers/library_jobs.rs @@ -14,16 +14,16 @@ use crate::api::{ extractors::{AppState, AuthContext}, permissions::Permission, }; -use crate::db::entities::library_jobs; -use crate::db::repositories::{ - CreateLibraryJobParams, LibraryJobRepository, LibraryRepository, SeriesRepository, -}; use crate::require_permission; use crate::services::library_jobs::{ LibraryJobConfig, MetadataRefreshJobConfig, parse_job_config, validation, }; use crate::services::metadata::{FieldGroup, RefreshPlanner, fields_for_group}; use crate::tasks::types::TaskType; +use codex_db::entities::library_jobs; +use codex_db::repositories::{ + CreateLibraryJobParams, LibraryJobRepository, LibraryRepository, SeriesRepository, +}; use super::super::dto::patch::PatchValue; use super::super::dto::{ @@ -332,7 +332,7 @@ pub async fn run_job_now( )); } - let task_id = crate::db::repositories::TaskRepository::enqueue( + let task_id = codex_db::repositories::TaskRepository::enqueue( &state.db, TaskType::RefreshLibraryMetadata { job_id }, None, diff --git a/src/api/routes/v1/handlers/metrics.rs b/src/api/routes/v1/handlers/metrics.rs index e926d67a..68226ea2 100644 --- a/src/api/routes/v1/handlers/metrics.rs +++ b/src/api/routes/v1/handlers/metrics.rs @@ -7,7 +7,7 @@ use super::super::dto::{ PluginMetricsSummaryDto, }; use crate::api::{AppState, error::ApiError, extractors::AuthContext, permissions::Permission}; -use crate::db::repositories::MetricsRepository; +use codex_db::repositories::MetricsRepository; /// Get inventory metrics (library/book counts) /// diff --git a/src/api/routes/v1/handlers/oidc.rs b/src/api/routes/v1/handlers/oidc.rs index 9e43f310..7751d944 100644 --- a/src/api/routes/v1/handlers/oidc.rs +++ b/src/api/routes/v1/handlers/oidc.rs @@ -9,10 +9,6 @@ use super::super::dto::{ }; use super::auth::build_auth_cookie; use crate::api::{error::ApiError, extractors::AppState, permissions::UserRole}; -use crate::db::{ - entities::users, - repositories::{OidcConnectionRepository, UserRepository}, -}; use axum::{ Json, extract::{Path, Query, State}, @@ -21,6 +17,10 @@ use axum::{ }; use base64::{Engine as _, engine::general_purpose}; use chrono::Utc; +use codex_db::{ + entities::users, + repositories::{OidcConnectionRepository, UserRepository}, +}; use std::sync::Arc; use tracing::{debug, info, warn}; use uuid::Uuid; @@ -322,7 +322,7 @@ pub async fn callback( }; // Create OIDC connection - let connection = crate::db::entities::oidc_connections::Model { + let connection = codex_db::entities::oidc_connections::Model { id: Uuid::new_v4(), user_id: user.id, provider_name: provider.clone(), @@ -589,7 +589,7 @@ mod tests { // Integration tests for async functions that need a database mod db_tests { use super::*; - use crate::db::repositories::UserRepository; + use codex_db::repositories::UserRepository; use sea_orm::Database; async fn setup_test_db() -> sea_orm::DatabaseConnection { diff --git a/src/api/routes/v1/handlers/pages.rs b/src/api/routes/v1/handlers/pages.rs index eedb6e3c..faf7384d 100644 --- a/src/api/routes/v1/handlers/pages.rs +++ b/src/api/routes/v1/handlers/pages.rs @@ -3,7 +3,6 @@ use crate::api::{ extractors::{AuthState, FlexibleAuthContext}, permissions::Permission, }; -use crate::db::repositories::{BookCoversRepository, BookRepository, PageRepository}; use crate::require_permission; use axum::{ body::Body, @@ -11,6 +10,7 @@ use axum::{ http::{HeaderMap, StatusCode, header}, response::Response, }; +use codex_db::repositories::{BookCoversRepository, BookRepository, PageRepository}; use codex_utils::{DeadlineResult, with_deadline}; use httpdate::fmt_http_date; use image::{ImageFormat, imageops::FilterType}; @@ -546,7 +546,7 @@ pub async fn get_book_thumbnail( /// Generate a thumbnail for a book (handles extraction, resizing, and caching) async fn generate_book_thumbnail( state: &Arc<AuthState>, - book: &crate::db::entities::books::Model, + book: &codex_db::entities::books::Model, ) -> Result<Vec<u8>, ApiError> { let book_id = book.id; diff --git a/src/api/routes/v1/handlers/pdf_cache.rs b/src/api/routes/v1/handlers/pdf_cache.rs index 91d470c5..55b07c42 100644 --- a/src/api/routes/v1/handlers/pdf_cache.rs +++ b/src/api/routes/v1/handlers/pdf_cache.rs @@ -20,9 +20,9 @@ use crate::api::{ extractors::{AppState, AuthContext}, permissions::Permission, }; -use crate::db::repositories::TaskRepository; use crate::require_permission; use crate::tasks::types::TaskType; +use codex_db::repositories::TaskRepository; /// Build the page-cache stats DTO from the current AppState. async fn page_cache_stats(state: &AppState) -> Result<PdfPageCacheStatsDto, ApiError> { diff --git a/src/api/routes/v1/handlers/plugin_actions.rs b/src/api/routes/v1/handlers/plugin_actions.rs index 041f4a52..af07bbf8 100644 --- a/src/api/routes/v1/handlers/plugin_actions.rs +++ b/src/api/routes/v1/handlers/plugin_actions.rs @@ -20,13 +20,6 @@ use super::super::dto::{ PreviewSummary, SearchTitleResponse, SkippedField, parse_scope, }; use crate::api::{AppState, error::ApiError, extractors::AuthContext, permissions::Permission}; -use crate::db::entities::plugins::PluginPermission; -use crate::db::repositories::{ - AlternateTitleRepository, BookExternalIdRepository, BookMetadataRepository, BookRepository, - ExternalLinkRepository, ExternalRatingRepository, GenreRepository, LibraryRepository, - PluginsRepository, SeriesExternalIdRepository, SeriesMetadataRepository, SeriesRepository, - TagRepository, TaskRepository, -}; use crate::services::metadata::preprocessing::{ PreprocessingRule, SeriesContextBuilder, apply_rules, render_template, }; @@ -43,6 +36,13 @@ use axum::{ Json, extract::{Path, Query, State}, }; +use codex_db::entities::plugins::PluginPermission; +use codex_db::repositories::{ + AlternateTitleRepository, BookExternalIdRepository, BookMetadataRepository, BookRepository, + ExternalLinkRepository, ExternalRatingRepository, GenreRepository, LibraryRepository, + PluginsRepository, SeriesExternalIdRepository, SeriesMetadataRepository, SeriesRepository, + TagRepository, TaskRepository, +}; use sea_orm::prelude::Decimal; use serde::Deserialize; use std::collections::{HashMap, HashSet}; diff --git a/src/api/routes/v1/handlers/plugins.rs b/src/api/routes/v1/handlers/plugins.rs index 57515ccd..79779103 100644 --- a/src/api/routes/v1/handlers/plugins.rs +++ b/src/api/routes/v1/handlers/plugins.rs @@ -11,8 +11,6 @@ use super::super::dto::{ parse_permission, parse_scope, }; use crate::api::{AppState, error::ApiError, extractors::AuthContext, permissions::Permission}; -use crate::db::entities::plugins::{InternalPluginConfig, PluginPermission}; -use crate::db::repositories::{PluginFailuresRepository, PluginsRepository, UserPluginsRepository}; use crate::services::PluginHealthStatus; use crate::services::plugin::process::{allowed_commands_description, is_command_allowed}; use crate::services::plugin::protocol::PluginScope; @@ -21,6 +19,8 @@ use axum::{ extract::{Path, State}, http::StatusCode, }; +use codex_db::entities::plugins::{InternalPluginConfig, PluginPermission}; +use codex_db::repositories::{PluginFailuresRepository, PluginsRepository, UserPluginsRepository}; use codex_events::{EntityChangeEvent, EntityEvent}; use std::sync::Arc; use std::time::Instant; @@ -683,7 +683,7 @@ pub async fn delete_plugin( // Done before the plugin row is dropped to avoid the brief window // where the orphan would be visible. Cascade on // `fk_release_ledger_source_id` carries any associated ledger rows. - match crate::db::repositories::ReleaseSourceRepository::delete_by_plugin_uuid(&state.db, id) + match codex_db::repositories::ReleaseSourceRepository::delete_by_plugin_uuid(&state.db, id) .await { Ok(0) => {} diff --git a/src/api/routes/v1/handlers/read_progress.rs b/src/api/routes/v1/handlers/read_progress.rs index 119785be..1de66e5a 100644 --- a/src/api/routes/v1/handlers/read_progress.rs +++ b/src/api/routes/v1/handlers/read_progress.rs @@ -2,13 +2,13 @@ use super::super::dto::{ MarkReadResponse, ReadProgressListResponse, ReadProgressResponse, UpdateProgressRequest, }; use crate::api::{AppState, error::ApiError, extractors::AuthContext, permissions::Permission}; -use crate::db::repositories::{BookRepository, ReadProgressRepository}; use axum::{ Json, extract::{Path, State}, http::StatusCode, response::{IntoResponse, Response}, }; +use codex_db::repositories::{BookRepository, ReadProgressRepository}; use std::sync::Arc; use utoipa::OpenApi; use uuid::Uuid; diff --git a/src/api/routes/v1/handlers/recommendations.rs b/src/api/routes/v1/handlers/recommendations.rs index 66aaedbc..be245942 100644 --- a/src/api/routes/v1/handlers/recommendations.rs +++ b/src/api/routes/v1/handlers/recommendations.rs @@ -10,10 +10,6 @@ use super::super::dto::recommendations::{ }; use crate::api::extractors::auth::AuthContext; use crate::api::{error::ApiError, extractors::AppState}; -use crate::db::repositories::{ - PluginsRepository, SeriesExternalIdRepository, TaskRepository, UserPluginDataRepository, - UserPluginsRepository, -}; use crate::services::plugin::protocol::PluginManifest; use crate::services::plugin::recommendations::RecommendationResponse; use crate::tasks::types::TaskType; @@ -22,6 +18,10 @@ use axum::{ extract::{Path, State}, }; use chrono::Utc; +use codex_db::repositories::{ + PluginsRepository, SeriesExternalIdRepository, TaskRepository, UserPluginDataRepository, + UserPluginsRepository, +}; use std::sync::Arc; use tracing::{debug, info, warn}; use uuid::Uuid; @@ -35,8 +35,8 @@ async fn find_recommendation_plugin( user_id: Uuid, ) -> Result< ( - crate::db::entities::plugins::Model, - crate::db::entities::user_plugins::Model, + codex_db::entities::plugins::Model, + codex_db::entities::user_plugins::Model, ), ApiError, > { @@ -299,7 +299,7 @@ pub async fn refresh_recommendations( async fn enrich_and_filter_codex_presence( db: &sea_orm::DatabaseConnection, recommendations: &mut [RecommendationDto], - plugin: &crate::db::entities::plugins::Model, + plugin: &codex_db::entities::plugins::Model, ) { // Resolve the external_id_source from the plugin manifest let source = plugin @@ -535,7 +535,7 @@ mod tests { /// when all optional fields are populated. #[test] fn test_to_recommendation_dto_full_fields() { - use crate::db::entities::SeriesStatus; + use codex_db::entities::SeriesStatus; let rec = Recommendation { external_id: "12345".to_string(), @@ -638,7 +638,7 @@ mod tests { /// Verify the full RecommendationsResponse can be serialized with the expected JSON shape. #[test] fn test_recommendations_response_json_shape() { - use crate::db::entities::SeriesStatus; + use codex_db::entities::SeriesStatus; let recs = vec![ to_recommendation_dto(Recommendation { diff --git a/src/api/routes/v1/handlers/releases.rs b/src/api/routes/v1/handlers/releases.rs index 6bb68564..072afe54 100644 --- a/src/api/routes/v1/handlers/releases.rs +++ b/src/api/routes/v1/handlers/releases.rs @@ -40,8 +40,8 @@ use crate::api::{ extractors::{AuthContext, AuthState}, permissions::Permission, }; -use crate::db::entities::release_ledger::state as ledger_state; -use crate::db::repositories::{ +use codex_db::entities::release_ledger::state as ledger_state; +use codex_db::repositories::{ LedgerInboxFilter, LibraryRepository, PluginsRepository, ReleaseLedgerRepository, ReleaseSourceRepository, ReleaseSourceUpdate, SeriesRepository, }; @@ -55,7 +55,7 @@ use codex_events::{EntityChangeEvent, EntityEvent}; /// existing `SeriesRepository::get_by_ids` batch query. async fn hydrate_ledger_dtos( db: &sea_orm::DatabaseConnection, - rows: Vec<crate::db::entities::release_ledger::Model>, + rows: Vec<codex_db::entities::release_ledger::Model>, ) -> Result<Vec<ReleaseLedgerEntryDto>, ApiError> { let mut series_ids: Vec<Uuid> = rows.iter().map(|r| r.series_id).collect(); series_ids.sort_unstable(); diff --git a/src/api/routes/v1/handlers/scan.rs b/src/api/routes/v1/handlers/scan.rs index fb178174..29bc402d 100644 --- a/src/api/routes/v1/handlers/scan.rs +++ b/src/api/routes/v1/handlers/scan.rs @@ -14,11 +14,9 @@ use uuid::Uuid; use super::super::dto::{ScanStatusDto, TriggerScanQuery}; use super::task_queue::CreateTaskResponse; use crate::api::{AppState, error::ApiError, extractors::AuthContext, permissions::Permission}; -use crate::db::repositories::{ - BookRepository, LibraryRepository, SeriesRepository, TaskRepository, -}; use crate::scanner::ScanMode; use crate::tasks::types::TaskType; +use codex_db::repositories::{BookRepository, LibraryRepository, SeriesRepository, TaskRepository}; /// Trigger a library scan /// @@ -63,7 +61,7 @@ pub async fn trigger_scan( let mode = ScanMode::from_str(¶ms.mode).map_err(ApiError::BadRequest)?; // Check if there's already a pending/processing scan for this library - use crate::db::entities::{prelude::*, tasks}; + use codex_db::entities::{prelude::*, tasks}; use sea_orm::{ColumnTrait, EntityTrait, QueryFilter}; let existing_scan = Tasks::find() @@ -139,7 +137,7 @@ pub async fn get_scan_status( auth.require_permission(&Permission::LibrariesRead)?; // Find the most recent scan task for this library - use crate::db::entities::{prelude::*, tasks}; + use codex_db::entities::{prelude::*, tasks}; use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, QueryOrder}; let task = Tasks::find() @@ -199,7 +197,7 @@ pub async fn cancel_scan( auth.require_permission(&Permission::LibrariesWrite)?; // Find the active scan task for this library - use crate::db::entities::{prelude::*, tasks}; + use codex_db::entities::{prelude::*, tasks}; use sea_orm::{ColumnTrait, EntityTrait, QueryFilter}; let task = Tasks::find() @@ -244,7 +242,7 @@ pub async fn list_active_scans( auth.require_permission(&Permission::LibrariesRead)?; // Get all active scan tasks - use crate::db::entities::{prelude::*, tasks}; + use codex_db::entities::{prelude::*, tasks}; use sea_orm::{ColumnTrait, EntityTrait, QueryFilter}; let tasks = Tasks::find() @@ -531,7 +529,7 @@ pub async fn trigger_library_analysis( .ok_or_else(|| ApiError::NotFound("Library not found".to_string()))?; // Get all books in the library (including already analyzed) - use crate::db::repositories::SeriesRepository; + use codex_db::repositories::SeriesRepository; let series_list = SeriesRepository::list_by_library(&state.db, library_id) .await .map_err(|e| ApiError::Internal(format!("Failed to get series: {}", e)))?; diff --git a/src/api/routes/v1/handlers/series.rs b/src/api/routes/v1/handlers/series.rs index c625312b..7d4e7a95 100644 --- a/src/api/routes/v1/handlers/series.rs +++ b/src/api/routes/v1/handlers/series.rs @@ -26,13 +26,6 @@ use crate::api::{ extractors::{AuthContext, AuthState, ContentFilter, FlexibleAuthContext}, permissions::Permission, }; -use crate::db::entities::{series, series_metadata}; -use crate::db::repositories::{ - AlternateTitleRepository, BookRepository, ExternalLinkRepository, ExternalRatingRepository, - GenreRepository, LibraryRepository, ReadProgressRepository, SeriesCoversRepository, - SeriesExternalIdRepository, SeriesMetadataRepository, SeriesRepository, - SeriesTrackingRepository, SharingTagRepository, TagRepository, UserSeriesRatingRepository, -}; use crate::require_permission; use crate::services::release::upstream_gap::{ UpstreamGap, UpstreamGapInputs, compute_upstream_gap, @@ -45,6 +38,13 @@ use axum::{ response::{IntoResponse, Response}, }; use chrono::Utc; +use codex_db::entities::{series, series_metadata}; +use codex_db::repositories::{ + AlternateTitleRepository, BookRepository, ExternalLinkRepository, ExternalRatingRepository, + GenreRepository, LibraryRepository, ReadProgressRepository, SeriesCoversRepository, + SeriesExternalIdRepository, SeriesMetadataRepository, SeriesRepository, + SeriesTrackingRepository, SharingTagRepository, TagRepository, UserSeriesRatingRepository, +}; use codex_events::{EntityChangeEvent, EntityEvent, EntityType}; use codex_utils::{ json_merge_patch, normalize_for_search, parse_custom_metadata, serialize_custom_metadata, @@ -1091,7 +1091,7 @@ pub async fn search_series( .await .map_err(|e| ApiError::Internal(format!("Failed to load content filter: {}", e)))?; - let fuzzy_enabled = crate::db::repositories::SettingsRepository::get_value::<bool>( + let fuzzy_enabled = codex_db::repositories::SettingsRepository::get_value::<bool>( &state.db, "search.fuzzy.enabled", ) @@ -1194,7 +1194,7 @@ pub async fn list_series_filtered( let (page, page_size) = pagination.validated(); let offset = (page - 1) * page_size; - let fuzzy_enabled = crate::db::repositories::SettingsRepository::get_value::<bool>( + let fuzzy_enabled = codex_db::repositories::SettingsRepository::get_value::<bool>( &state.db, "search.fuzzy.enabled", ) @@ -1983,8 +1983,8 @@ pub async fn get_series_thumbnail( ); // Queue the thumbnail generation task (fire and forget) - use crate::db::repositories::TaskRepository; use crate::tasks::types::TaskType; + use codex_db::repositories::TaskRepository; let task_type = TaskType::GenerateSeriesThumbnail { series_id, @@ -2996,10 +2996,10 @@ pub async fn reset_series_metadata( .map_err(|e| ApiError::Internal(format!("Failed to clear sharing tags: {}", e)))?; // Delete metadata sources - use crate::db::entities::metadata_sources::Entity as MetadataSources; + use codex_db::entities::metadata_sources::Entity as MetadataSources; use sea_orm::{ColumnTrait, EntityTrait, QueryFilter}; MetadataSources::delete_many() - .filter(crate::db::entities::metadata_sources::Column::SeriesId.eq(series_id)) + .filter(codex_db::entities::metadata_sources::Column::SeriesId.eq(series_id)) .exec(&state.db) .await .map_err(|e| ApiError::Internal(format!("Failed to clear metadata sources: {}", e)))?; @@ -5887,8 +5887,8 @@ pub async fn get_series_cover_image( /// This should be called whenever a series cover is selected/unselected to ensure /// the cached thumbnail reflects the current cover selection. async fn regenerate_series_thumbnail(state: &AuthState, series_id: Uuid) { - use crate::db::repositories::TaskRepository; use crate::tasks::types::TaskType; + use codex_db::repositories::TaskRepository; // Delete the cached series thumbnail first if let Err(e) = state diff --git a/src/api/routes/v1/handlers/series_exports.rs b/src/api/routes/v1/handlers/series_exports.rs index 4d4bf81f..16be85da 100644 --- a/src/api/routes/v1/handlers/series_exports.rs +++ b/src/api/routes/v1/handlers/series_exports.rs @@ -12,10 +12,10 @@ use uuid::Uuid; use crate::api::error::ApiError; use crate::api::extractors::auth::{AppState, AuthContext}; -use crate::db::repositories::{SeriesExportRepository, TaskRepository}; use crate::services::book_export_collector::BookExportField; use crate::services::series_export_collector::ExportField; use crate::tasks::types::TaskType; +use codex_db::repositories::{SeriesExportRepository, TaskRepository}; use super::super::dto::series_export::{ CreateSeriesExportRequest, ExportFieldCatalogResponse, ExportFieldDto, ExportPresetsDto, diff --git a/src/api/routes/v1/handlers/settings.rs b/src/api/routes/v1/handlers/settings.rs index 55152050..cb354993 100644 --- a/src/api/routes/v1/handlers/settings.rs +++ b/src/api/routes/v1/handlers/settings.rs @@ -7,12 +7,12 @@ use crate::api::{ extractors::{AuthContext, AuthState}, permissions::Permission, }; -use crate::db::repositories::SettingsRepository; use crate::require_permission; use axum::{ Json, extract::{Path, Query, State}, }; +use codex_db::repositories::SettingsRepository; use std::collections::HashMap; use std::sync::Arc; diff --git a/src/api/routes/v1/handlers/setup.rs b/src/api/routes/v1/handlers/setup.rs index d16da3c8..8b416cdd 100644 --- a/src/api/routes/v1/handlers/setup.rs +++ b/src/api/routes/v1/handlers/setup.rs @@ -8,10 +8,6 @@ use crate::api::{ extractors::{AuthContext, AuthState}, permissions::Permission, }; -use crate::db::{ - entities::users, - repositories::{SettingsRepository, UserRepository}, -}; use crate::require_permission; use axum::{ Json, @@ -20,6 +16,10 @@ use axum::{ response::{IntoResponse, Response}, }; use chrono::Utc; +use codex_db::{ + entities::users, + repositories::{SettingsRepository, UserRepository}, +}; use codex_utils::password; use std::sync::Arc; use uuid::Uuid; @@ -263,7 +263,7 @@ pub async fn configure_initial_settings( } // Import SettingsRepository to update settings - use crate::db::repositories::SettingsRepository; + use codex_db::repositories::SettingsRepository; let mut configured_count = 0; diff --git a/src/api/routes/v1/handlers/sharing_tags.rs b/src/api/routes/v1/handlers/sharing_tags.rs index 79ae0ba5..d2961bf1 100644 --- a/src/api/routes/v1/handlers/sharing_tags.rs +++ b/src/api/routes/v1/handlers/sharing_tags.rs @@ -17,13 +17,13 @@ use crate::api::{ extractors::{AuthContext, AuthState}, permissions::Permission, }; -use crate::db::repositories::SharingTagRepository; use axum::{ Json, extract::{Path, Query, State}, http::StatusCode, response::Response, }; +use codex_db::repositories::SharingTagRepository; use std::sync::Arc; use uuid::Uuid; diff --git a/src/api/routes/v1/handlers/task_metrics.rs b/src/api/routes/v1/handlers/task_metrics.rs index 72506c79..3c37535f 100644 --- a/src/api/routes/v1/handlers/task_metrics.rs +++ b/src/api/routes/v1/handlers/task_metrics.rs @@ -8,7 +8,7 @@ use super::super::dto::{ TaskMetricsSummaryDto, TaskTypeMetricsDto, }; use crate::api::{AppState, error::ApiError, extractors::AuthContext, permissions::Permission}; -use crate::db::repositories::TaskRepository; +use codex_db::repositories::TaskRepository; /// Get current task metrics /// diff --git a/src/api/routes/v1/handlers/task_queue.rs b/src/api/routes/v1/handlers/task_queue.rs index f630d19d..8c471cc8 100644 --- a/src/api/routes/v1/handlers/task_queue.rs +++ b/src/api/routes/v1/handlers/task_queue.rs @@ -9,11 +9,11 @@ use utoipa::ToSchema; use uuid::Uuid; use crate::api::{error::ApiError, extractors::AuthContext, permissions::Permission}; -use crate::db::repositories::{ - LibraryRepository, SeriesMetadataRepository, SeriesRepository, TaskRepository, -}; use crate::require_permission; use crate::tasks::types::{TaskStats, TaskType}; +use codex_db::repositories::{ + LibraryRepository, SeriesMetadataRepository, SeriesRepository, TaskRepository, +}; use super::super::dto::series::{ EnqueueReprocessTitleRequest, EnqueueReprocessTitleResponse, ReprocessSeriesTitlesRequest, @@ -143,8 +143,8 @@ pub struct TaskResponse { pub library_name: Option<String>, } -impl From<crate::db::entities::tasks::Model> for TaskResponse { - fn from(task: crate::db::entities::tasks::Model) -> Self { +impl From<codex_db::entities::tasks::Model> for TaskResponse { + fn from(task: codex_db::entities::tasks::Model) -> Self { Self { id: task.id, task_type: task.task_type, @@ -171,8 +171,8 @@ impl From<crate::db::entities::tasks::Model> for TaskResponse { } } -impl From<crate::db::repositories::task::TaskWithTargets> for TaskResponse { - fn from(enriched: crate::db::repositories::task::TaskWithTargets) -> Self { +impl From<codex_db::repositories::task::TaskWithTargets> for TaskResponse { + fn from(enriched: codex_db::repositories::task::TaskWithTargets) -> Self { let mut response = Self::from(enriched.task); response.book_title = enriched.book_title; response.series_title = enriched.series_title; @@ -655,7 +655,7 @@ pub async fn generate_book_thumbnails( // Validate scope IDs if no explicit book_ids or series_ids provided if request.book_ids.is_none() && request.series_ids.is_none() { if let Some(library_id) = request.library_id { - use crate::db::repositories::LibraryRepository; + use codex_db::repositories::LibraryRepository; LibraryRepository::get_by_id(&state.db, library_id) .await .map_err(|e| ApiError::Internal(format!("Failed to check library: {}", e)))? @@ -663,7 +663,7 @@ pub async fn generate_book_thumbnails( } if let Some(series_id) = request.series_id { - use crate::db::repositories::SeriesRepository; + use codex_db::repositories::SeriesRepository; SeriesRepository::get_by_id(&state.db, series_id) .await .map_err(|e| ApiError::Internal(format!("Failed to check series: {}", e)))? @@ -724,7 +724,7 @@ pub async fn generate_library_book_thumbnails( auth: AuthContext, Json(request): Json<ForceRequest>, ) -> Result<Json<CreateTaskResponse>, ApiError> { - use crate::db::repositories::LibraryRepository; + use codex_db::repositories::LibraryRepository; // Check permission auth.require_permission(&Permission::TasksWrite)?; @@ -780,7 +780,7 @@ pub async fn generate_book_thumbnail( auth: AuthContext, Json(request): Json<ForceRequest>, ) -> Result<Json<CreateTaskResponse>, ApiError> { - use crate::db::repositories::BookRepository; + use codex_db::repositories::BookRepository; // Check permission auth.require_permission(&Permission::TasksWrite)?; @@ -834,7 +834,7 @@ pub async fn generate_series_thumbnail( auth: AuthContext, Json(request): Json<ForceRequest>, ) -> Result<Json<CreateTaskResponse>, ApiError> { - use crate::db::repositories::SeriesRepository; + use codex_db::repositories::SeriesRepository; // Check permission auth.require_permission(&Permission::TasksWrite)?; @@ -904,7 +904,7 @@ pub async fn generate_series_thumbnails( if request.series_ids.is_none() && let Some(library_id) = request.library_id { - use crate::db::repositories::LibraryRepository; + use codex_db::repositories::LibraryRepository; LibraryRepository::get_by_id(&state.db, library_id) .await .map_err(|e| ApiError::Internal(format!("Failed to check library: {}", e)))? diff --git a/src/api/routes/v1/handlers/tracking.rs b/src/api/routes/v1/handlers/tracking.rs index 7d9fe8f8..38c7f602 100644 --- a/src/api/routes/v1/handlers/tracking.rs +++ b/src/api/routes/v1/handlers/tracking.rs @@ -25,12 +25,12 @@ use crate::api::{ extractors::{AuthContext, AuthState}, permissions::Permission, }; -use crate::db::entities::series_aliases::alias_source; -use crate::db::repositories::{ - SeriesAliasRepository, SeriesRepository, SeriesTrackingRepository, TrackingUpdate, -}; use crate::require_permission; use crate::services::release::seed::seed_tracking_for_series; +use codex_db::entities::series_aliases::alias_source; +use codex_db::repositories::{ + SeriesAliasRepository, SeriesRepository, SeriesTrackingRepository, TrackingUpdate, +}; use codex_events::{EntityChangeEvent, EntityEvent}; // ============================================================================= diff --git a/src/api/routes/v1/handlers/user_plugins.rs b/src/api/routes/v1/handlers/user_plugins.rs index c9e9699d..719461be 100644 --- a/src/api/routes/v1/handlers/user_plugins.rs +++ b/src/api/routes/v1/handlers/user_plugins.rs @@ -13,9 +13,6 @@ use super::super::dto::user_plugins::{ }; use crate::api::extractors::auth::AuthContext; use crate::api::{error::ApiError, extractors::AppState}; -use crate::db::repositories::{ - PluginsRepository, TaskRepository, UserPluginDataRepository, UserPluginsRepository, -}; use crate::services::plugin::protocol::{OAuthConfig, PluginManifest, methods}; use crate::services::plugin::sync::SyncStatusResponse; use crate::tasks::handlers::user_plugin_sync::LAST_SYNC_RESULT_KEY; @@ -25,13 +22,16 @@ use axum::{ extract::{Path, Query, State}, http::HeaderMap, }; +use codex_db::repositories::{ + PluginsRepository, TaskRepository, UserPluginDataRepository, UserPluginsRepository, +}; use std::sync::Arc; use tracing::{debug, info, warn}; use uuid::Uuid; /// Parse a plugin's manifest JSON into a typed PluginManifest. /// Deserializes once and caches the result for callers that need multiple fields. -fn parse_manifest(plugin: &crate::db::entities::plugins::Model) -> Option<PluginManifest> { +fn parse_manifest(plugin: &codex_db::entities::plugins::Model) -> Option<PluginManifest> { plugin .manifest .as_ref() @@ -40,7 +40,7 @@ fn parse_manifest(plugin: &crate::db::entities::plugins::Model) -> Option<Plugin /// Helper to extract OAuth config from a plugin's stored manifest fn get_oauth_config_from_plugin( - plugin: &crate::db::entities::plugins::Model, + plugin: &codex_db::entities::plugins::Model, ) -> Option<OAuthConfig> { parse_manifest(plugin).and_then(|m| m.oauth) } @@ -48,7 +48,7 @@ fn get_oauth_config_from_plugin( /// Helper to get the OAuth client_id for a plugin. /// /// Priority: plugin config > manifest default -fn get_oauth_client_id(plugin: &crate::db::entities::plugins::Model) -> Option<String> { +fn get_oauth_client_id(plugin: &codex_db::entities::plugins::Model) -> Option<String> { // Check plugin config for client_id override if let Some(client_id) = plugin .config @@ -64,7 +64,7 @@ fn get_oauth_client_id(plugin: &crate::db::entities::plugins::Model) -> Option<S } /// Helper to get OAuth client_secret from plugin config -fn get_oauth_client_secret(plugin: &crate::db::entities::plugins::Model) -> Option<String> { +fn get_oauth_client_secret(plugin: &codex_db::entities::plugins::Model) -> Option<String> { plugin .config .get("oauth_client_secret") @@ -103,8 +103,8 @@ fn resolve_oauth_redirect_base(state: &AppState, headers: &HeaderMap) -> String /// If `None`, fetches the last sync result from the database (1 query). async fn build_user_plugin_dto( db: &sea_orm::DatabaseConnection, - instance: &crate::db::entities::user_plugins::Model, - plugin: &crate::db::entities::plugins::Model, + instance: &codex_db::entities::user_plugins::Model, + plugin: &codex_db::entities::plugins::Model, prefetched_sync_result: Option<Option<serde_json::Value>>, ) -> UserPluginDto { let manifest = parse_manifest(plugin); diff --git a/src/api/routes/v1/handlers/user_preferences.rs b/src/api/routes/v1/handlers/user_preferences.rs index 62b3fd29..74ae3bc8 100644 --- a/src/api/routes/v1/handlers/user_preferences.rs +++ b/src/api/routes/v1/handlers/user_preferences.rs @@ -5,11 +5,11 @@ use super::super::dto::{ SetPreferencesResponse, UserPreferenceDto, UserPreferencesResponse, }; use crate::api::{AppState, error::ApiError, extractors::AuthContext}; -use crate::db::repositories::UserPreferencesRepository; use axum::{ Json, extract::{Path, State}, }; +use codex_db::repositories::UserPreferencesRepository; use std::sync::Arc; use utoipa::OpenApi; diff --git a/src/api/routes/v1/handlers/users.rs b/src/api/routes/v1/handlers/users.rs index 6ab6fd8d..4dbe9bf1 100644 --- a/src/api/routes/v1/handlers/users.rs +++ b/src/api/routes/v1/handlers/users.rs @@ -8,8 +8,6 @@ use crate::api::{ extractors::{AuthContext, AuthState}, permissions::{Permission, UserRole}, }; -use crate::db::entities::users; -use crate::db::repositories::{SharingTagRepository, UserListFilter, UserRepository}; use crate::require_permission; use axum::{ Json, @@ -17,6 +15,8 @@ use axum::{ response::Response, }; use chrono::Utc; +use codex_db::entities::users; +use codex_db::repositories::{SharingTagRepository, UserListFilter, UserRepository}; use codex_utils::password; use std::sync::Arc; use uuid::Uuid; diff --git a/src/commands/common.rs b/src/commands/common.rs index 72882ec5..d824534a 100644 --- a/src/commands/common.rs +++ b/src/commands/common.rs @@ -1,8 +1,8 @@ -use crate::db::Database; use crate::observability::ObservabilityHandle; use crate::services::{SettingsService, TaskMetricsService}; use crate::tasks::TaskWorker; use codex_config::{Config, DatabaseConfig, DatabaseType, EnvOverride}; +use codex_db::Database; use codex_events::EventBroadcaster; use sea_orm::DatabaseConnection; use std::fs; @@ -583,9 +583,9 @@ pub async fn shutdown_workers( #[cfg(test)] mod tests { use super::*; - use crate::db::test_helpers::create_test_db; use crate::services::SettingsService; use codex_config::{FilesConfig, SQLiteConfig, TaskConfig}; + use codex_db::test_helpers::create_test_db; use tempfile::TempDir; #[test] diff --git a/src/commands/migrate.rs b/src/commands/migrate.rs index e29d3104..3c9c2983 100644 --- a/src/commands/migrate.rs +++ b/src/commands/migrate.rs @@ -1,6 +1,6 @@ use crate::commands::common::{display_database_config, init_tracing, load_config}; -use crate::db::Database; use anyhow::{Context, Result}; +use codex_db::Database; use std::path::PathBuf; use tracing::info; diff --git a/src/commands/seed.rs b/src/commands/seed.rs index 326d83e1..537a636d 100644 --- a/src/commands/seed.rs +++ b/src/commands/seed.rs @@ -1,16 +1,16 @@ use crate::api::permissions::{ ADMIN_PERMISSIONS, MAINTAINER_PERMISSIONS, READER_PERMISSIONS, serialize_permissions, }; -use crate::db::Database; -use crate::db::entities::{api_keys, plugins::PluginPermission, users}; -use crate::db::repositories::{ - api_key::ApiKeyRepository, library::CreateLibraryParams, library::LibraryRepository, - plugins::PluginsRepository, user::UserRepository, -}; use crate::services::plugin::protocol::PluginScope; use anyhow::{Context, Result}; use chrono::Utc; use codex_config::{Config, EnvOverride}; +use codex_db::Database; +use codex_db::entities::{api_keys, plugins::PluginPermission, users}; +use codex_db::repositories::{ + api_key::ApiKeyRepository, library::CreateLibraryParams, library::LibraryRepository, + plugins::PluginsRepository, user::UserRepository, +}; use codex_models::{BookStrategy, NumberStrategy, SeriesStrategy}; use codex_utils::password::hash_password; use rand::RngExt; diff --git a/src/commands/tasks.rs b/src/commands/tasks.rs index 989fb2da..93c6b6f0 100644 --- a/src/commands/tasks.rs +++ b/src/commands/tasks.rs @@ -6,9 +6,9 @@ use std::path::PathBuf; use uuid::Uuid; use crate::commands::common::{init_database, load_config}; -use crate::db::entities::prelude::Tasks; -use crate::db::entities::tasks; -use crate::db::repositories::TaskRepository; +use codex_db::entities::prelude::Tasks; +use codex_db::entities::tasks; +use codex_db::repositories::TaskRepository; /// Task queue management subcommands #[derive(Subcommand, Debug)] diff --git a/src/lib.rs b/src/lib.rs index c7ba6d49..2ebe8a04 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,5 +1,4 @@ pub mod api; -pub mod db; pub mod observability; pub mod scanner; pub mod scheduler; @@ -9,10 +8,11 @@ pub mod tasks; pub mod web; // Re-exports of workspace-leaf crates so existing `codex::config::*`, -// `codex::events::*`, `codex::models::*`, `codex::parsers::*`, and -// `codex::utils::*` paths (used pervasively in integration tests) keep +// `codex::db::*`, `codex::events::*`, `codex::models::*`, `codex::parsers::*`, +// and `codex::utils::*` paths (used pervasively in integration tests) keep // resolving without churn. pub use codex_config as config; +pub use codex_db as db; pub use codex_events as events; pub use codex_models as models; pub use codex_parsers as parsers; diff --git a/src/main.rs b/src/main.rs index 45ac6f49..5eca9028 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,5 @@ mod api; mod commands; -mod db; mod observability; mod scanner; mod scheduler; diff --git a/src/observability/inventory.rs b/src/observability/inventory.rs index ec471544..c6e816ce 100644 --- a/src/observability/inventory.rs +++ b/src/observability/inventory.rs @@ -14,7 +14,7 @@ use tokio::task::JoinHandle; use tokio_util::sync::CancellationToken; use tracing::warn; -use crate::db::repositories::MetricsRepository; +use codex_db::repositories::MetricsRepository; /// Spawn the inventory snapshot poller. Runs every `interval` until the /// cancellation token fires. @@ -68,7 +68,7 @@ mod tests { // queries return zero rather than erroring. The cheapest way to // exercise the refresh path end-to-end without coupling the test to // a fixture builder. - let db = crate::db::test_helpers::setup_test_db().await; + let db = codex_db::test_helpers::setup_test_db().await; // Pre-load known sentinel values so we can detect that the refresh // overwrote them with zeros (or any other DB count). diff --git a/src/observability/mod.rs b/src/observability/mod.rs index ce5a0be4..de0cc891 100644 --- a/src/observability/mod.rs +++ b/src/observability/mod.rs @@ -28,8 +28,6 @@ pub use stub::{ObservabilityHandle, TraceContextFormat, init}; mod http; pub use http::install_http_layers; -pub mod repo; - #[cfg(feature = "observability")] pub mod metrics; #[cfg(not(feature = "observability"))] diff --git a/src/scanner/analyzer_queue.rs b/src/scanner/analyzer_queue.rs index 5b02532d..940e0bd2 100644 --- a/src/scanner/analyzer_queue.rs +++ b/src/scanner/analyzer_queue.rs @@ -8,18 +8,18 @@ use tokio::sync::mpsc; use tracing::{debug, error, info, warn}; use uuid::Uuid; -use crate::db::entities::book_error::{BookError, BookErrorType}; -use crate::db::entities::{book_metadata, books, pages}; -use crate::db::repositories::{ - BookExternalLinkRepository, BookMetadataRepository, BookRepository, ExternalLinkRepository, - LibraryRepository, PageRepository, SeriesMetadataRepository, SeriesRepository, TaskRepository, -}; use crate::scanner::analyze_file; use crate::scanner::strategies::{ BookMetadata, BookNamingContext, NumberContext, NumberMetadata, create_book_strategy, create_number_strategy, }; use crate::tasks::types::TaskType; +use codex_db::entities::book_error::{BookError, BookErrorType}; +use codex_db::entities::{book_metadata, books, pages}; +use codex_db::repositories::{ + BookExternalLinkRepository, BookMetadataRepository, BookRepository, ExternalLinkRepository, + LibraryRepository, PageRepository, SeriesMetadataRepository, SeriesRepository, TaskRepository, +}; use codex_events::EventBroadcaster; use codex_models::{BookStrategy, CalibreStrategyConfig, NumberStrategy, SeriesStrategy}; use codex_parsers::opf; @@ -682,7 +682,7 @@ async fn analyze_single_book( if let Ok(Some(series_metadata_model)) = SeriesMetadataRepository::get_by_series_id(db, book.series_id).await { - use crate::db::entities::series_metadata; + use codex_db::entities::series_metadata; use sea_orm::{ActiveModelTrait, Set}; let series_title = series_metadata_model.title.clone(); @@ -920,7 +920,7 @@ async fn analyze_single_book( && series_metadata_model.title_sort.is_none() && !series_metadata_model.title_sort_lock { - use crate::db::entities::series_metadata; + use codex_db::entities::series_metadata; use sea_orm::{ActiveModelTrait, Set}; let series_title = series_metadata_model.title.clone(); @@ -951,7 +951,7 @@ async fn analyze_single_book( if let Ok(Some(series_metadata_model)) = SeriesMetadataRepository::get_by_series_id(db, book.series_id).await { - use crate::db::entities::series_metadata; + use codex_db::entities::series_metadata; use sea_orm::{ActiveModelTrait, Set}; let series_title = series_metadata_model.title.clone(); @@ -992,7 +992,7 @@ async fn analyze_single_book( && let Some(ref status) = sj_meta.status { // Map Mylar status to Codex SeriesStatus - use crate::db::entities::series_metadata::SeriesStatus; + use codex_db::entities::series_metadata::SeriesStatus; let codex_status = match status.to_lowercase().as_str() { "continuing" => "ongoing".to_string(), "ended" => "ended".to_string(), diff --git a/src/scanner/library_scanner.rs b/src/scanner/library_scanner.rs index e2c97b94..c5a94e89 100644 --- a/src/scanner/library_scanner.rs +++ b/src/scanner/library_scanner.rs @@ -14,11 +14,9 @@ use tracing::{debug, error, info, warn}; use uuid::Uuid; use walkdir::WalkDir; -use crate::db::entities::{books, series}; -use crate::db::repositories::{ - BookRepository, LibraryRepository, SeriesRepository, TaskRepository, -}; use crate::tasks::types::TaskType; +use codex_db::entities::{books, series}; +use codex_db::repositories::{BookRepository, LibraryRepository, SeriesRepository, TaskRepository}; use codex_events::{EventBroadcaster, TaskProgressEvent}; use codex_models::SeriesStrategy; @@ -29,7 +27,7 @@ const SUPPORTED_EXTENSIONS: &[&str] = &["cbz", "cbr", "epub", "pdf"]; /// Parse allowed_formats from library and convert to lowercase extensions /// Returns None if no restrictions (all formats allowed), or Some(Vec<String>) with allowed extensions -fn parse_allowed_formats(library: &crate::db::entities::libraries::Model) -> Option<Vec<String>> { +fn parse_allowed_formats(library: &codex_db::entities::libraries::Model) -> Option<Vec<String>> { library.allowed_formats.as_ref().and_then(|json| { serde_json::from_str::<Vec<String>>(json) .ok() @@ -53,7 +51,7 @@ fn parse_allowed_formats(library: &crate::db::entities::libraries::Model) -> Opt /// - `_to_filter` → matches any directory/file named `_to_filter` at any depth /// - `*.tmp` → matches any `.tmp` file at any depth /// - `subdir/*` → matches everything inside `subdir/` relative to library root -fn parse_excluded_patterns(library: &crate::db::entities::libraries::Model) -> Option<GlobSet> { +fn parse_excluded_patterns(library: &codex_db::entities::libraries::Model) -> Option<GlobSet> { library.excluded_patterns.as_ref().and_then(|patterns| { let mut builder = GlobSetBuilder::new(); let mut pattern_count = 0; @@ -562,7 +560,7 @@ pub async fn scan_library( /// - Uses thread-safe shared state for progress tracking async fn scan_batched( db: &DatabaseConnection, - library: &crate::db::entities::libraries::Model, + library: &codex_db::entities::libraries::Model, mode: ScanMode, progress_tx: Option<mpsc::Sender<ScanProgress>>, event_broadcaster: Option<&Arc<EventBroadcaster>>, @@ -953,7 +951,7 @@ async fn hash_files_parallel( #[allow(clippy::too_many_arguments)] async fn process_series_batched( db: &DatabaseConnection, - library: &crate::db::entities::libraries::Model, + library: &codex_db::entities::libraries::Model, detected_series: &DetectedSeries, existing_books_map: &HashMap<String, books::Model>, all_series_paths: &HashSet<String>, @@ -1268,8 +1266,8 @@ async fn find_or_create_series( preprocessing_rules: &[crate::services::metadata::preprocessing::PreprocessingRule], event_broadcaster: Option<&Arc<EventBroadcaster>>, ) -> Result<series::Model> { - use crate::db::repositories::SeriesMetadataRepository; use crate::services::metadata::preprocessing::apply_rules; + use codex_db::repositories::SeriesMetadataRepository; debug!( "find_or_create_series: name='{}', path='{}', fingerprint={:?}", @@ -1732,9 +1730,9 @@ mod tests { // Helper to create a minimal library model for testing fn create_test_library( excluded_patterns: Option<String>, - ) -> crate::db::entities::libraries::Model { + ) -> codex_db::entities::libraries::Model { use chrono::Utc; - crate::db::entities::libraries::Model { + codex_db::entities::libraries::Model { id: Uuid::new_v4(), name: "Test Library".to_string(), path: "/test/library".to_string(), diff --git a/src/scanner/types.rs b/src/scanner/types.rs index a7db1836..e4bc4be7 100644 --- a/src/scanner/types.rs +++ b/src/scanner/types.rs @@ -274,7 +274,7 @@ impl ScannerConfig { /// /// Falls back to defaults if settings are not found or invalid. pub async fn load(db: &sea_orm::DatabaseConnection) -> Self { - use crate::db::repositories::SettingsRepository; + use codex_db::repositories::SettingsRepository; let batch_size = SettingsRepository::get_value::<i64>(db, "scanner.batch_size") .await diff --git a/src/scheduler/mod.rs b/src/scheduler/mod.rs index 1a136ada..38e2dd9d 100644 --- a/src/scheduler/mod.rs +++ b/src/scheduler/mod.rs @@ -7,12 +7,12 @@ use tokio_cron_scheduler::{Job, JobScheduler}; use tracing::{debug, error, info, warn}; use uuid::Uuid; -use crate::db::entities::library_jobs; -use crate::db::repositories::{LibraryJobRepository, LibraryRepository, TaskRepository}; use crate::scanner::{ScanMode, ScanningConfig}; use crate::services::library_jobs::{LibraryJobConfig, parse_job_config}; use crate::services::settings::SettingsService; use crate::tasks::types::TaskType; +use codex_db::entities::library_jobs; +use codex_db::repositories::{LibraryJobRepository, LibraryRepository, TaskRepository}; use codex_utils::cron::{normalize_cron_expression, parse_timezone}; /// Generic scheduler for managing scheduled tasks (library scans, deduplication, etc.) @@ -770,7 +770,7 @@ impl crate::services::scheduler_handle::SchedulerReconciler for LockedSchedulerR /// /// `job_id` is stored inside `tasks.params` as JSON, so we use a backend- /// specific JSON path query — same pattern as -/// [`crate::db::repositories::TaskRepository::has_pending_or_processing`]. +/// [`codex_db::repositories::TaskRepository::has_pending_or_processing`]. pub async fn has_active_refresh_for_job(db: &DatabaseConnection, job_id: Uuid) -> Result<bool> { use sea_orm::{ConnectionTrait, DbBackend, Statement}; @@ -807,9 +807,9 @@ pub async fn has_active_refresh_for_job(db: &DatabaseConnection, job_id: Uuid) - #[cfg(test)] mod tests { use super::*; - use crate::db::repositories::LibraryRepository; - use crate::db::test_helpers::setup_test_db; use crate::tasks::types::TaskType; + use codex_db::repositories::LibraryRepository; + use codex_db::test_helpers::setup_test_db; use codex_models::ScanningStrategy; #[test] diff --git a/src/scheduler/release_sources.rs b/src/scheduler/release_sources.rs index 889207a4..22a93e11 100644 --- a/src/scheduler/release_sources.rs +++ b/src/scheduler/release_sources.rs @@ -23,10 +23,10 @@ use tokio_cron_scheduler::{Job, JobScheduler}; use tracing::{debug, error, info, warn}; use uuid::Uuid; -use crate::db::repositories::{ReleaseSourceRepository, TaskRepository}; use crate::services::release::schedule::{read_default_cron_schedule, resolve_cron_schedule}; use crate::services::settings::SettingsService; use crate::tasks::types::TaskType; +use codex_db::repositories::{ReleaseSourceRepository, TaskRepository}; use codex_utils::cron::normalize_cron_expression; /// Tracks scheduler-registered jobs per source row so we can reconcile. @@ -134,7 +134,7 @@ async fn register_one( scheduler: &mut JobScheduler, state: &mut ReleaseSourceSchedule, db: &DatabaseConnection, - source: &crate::db::entities::release_sources::Model, + source: &codex_db::entities::release_sources::Model, effective_cron: &str, ) -> Result<()> { // Normalize 5-field POSIX cron to the 6-field form tokio-cron-scheduler diff --git a/src/search/builder.rs b/src/search/builder.rs index 4acb7088..86ba276e 100644 --- a/src/search/builder.rs +++ b/src/search/builder.rs @@ -10,8 +10,8 @@ use serde_json::Value; use tracing::warn; use uuid::Uuid; -use crate::db::entities::{book_metadata, books, prelude::*, series, series_metadata}; -use crate::db::repositories::AlternateTitleRepository; +use codex_db::entities::{book_metadata, books, prelude::*, series, series_metadata}; +use codex_db::repositories::AlternateTitleRepository; use super::index::{BookEntry, BookSources, FuzzyIndex, SeriesEntry, SeriesSources}; @@ -256,14 +256,14 @@ fn parse_authors_names(authors_json: Option<&str>, series_id: Uuid) -> Vec<Strin #[cfg(test)] mod tests { use super::*; - use crate::db::ScanningStrategy; - use crate::db::entities::books; - use crate::db::repositories::{ + use chrono::Utc; + use codex_db::ScanningStrategy; + use codex_db::entities::books; + use codex_db::repositories::{ AlternateTitleRepository as AltRepo, BookRepository, LibraryRepository, SeriesMetadataRepository, SeriesRepository, }; - use crate::db::test_helpers::create_test_db; - use chrono::Utc; + use codex_db::test_helpers::create_test_db; fn book_model(series_id: Uuid, library_id: Uuid, path: &str, name: &str) -> books::Model { let now = Utc::now(); diff --git a/src/search/listener.rs b/src/search/listener.rs index 0be82e2b..438e57b9 100644 --- a/src/search/listener.rs +++ b/src/search/listener.rs @@ -213,15 +213,15 @@ async fn upsert_book(index: &FuzzyIndex, db: &DatabaseConnection, book_id: uuid: #[cfg(test)] mod tests { use super::*; - use crate::db::ScanningStrategy; - use crate::db::entities::books; - use crate::db::repositories::{ + use crate::search::builder::build_from_db; + use chrono::Utc; + use codex_db::ScanningStrategy; + use codex_db::entities::books; + use codex_db::repositories::{ AlternateTitleRepository, BookRepository, LibraryRepository, SeriesMetadataRepository, SeriesRepository, }; - use crate::db::test_helpers::create_test_db; - use crate::search::builder::build_from_db; - use chrono::Utc; + use codex_db::test_helpers::create_test_db; use codex_events::EntityChangeEvent; use std::time::Duration; use uuid::Uuid; @@ -255,7 +255,7 @@ mod tests { } async fn setup() -> ( - crate::db::Database, + codex_db::Database, Arc<FuzzyIndex>, Arc<EventBroadcaster>, Uuid, diff --git a/src/services/auth_tracking.rs b/src/services/auth_tracking.rs index 5098683b..0bdaa6ba 100644 --- a/src/services/auth_tracking.rs +++ b/src/services/auth_tracking.rs @@ -14,7 +14,7 @@ use tokio_util::sync::CancellationToken; use tracing::{debug, error}; use uuid::Uuid; -use crate::db::repositories::{ApiKeyRepository, UserRepository}; +use codex_db::repositories::{ApiKeyRepository, UserRepository}; /// Default flush interval in seconds (longer than read progress since timestamps /// don't need to be as precise) @@ -190,9 +190,9 @@ impl AuthTrackingService { #[cfg(test)] mod tests { use super::*; - use crate::db::entities::{api_keys, users}; - use crate::db::repositories::{ApiKeyRepository, UserRepository}; - use crate::db::test_helpers::setup_test_db; + use codex_db::entities::{api_keys, users}; + use codex_db::repositories::{ApiKeyRepository, UserRepository}; + use codex_db::test_helpers::setup_test_db; use codex_utils::password; use std::time::Duration; diff --git a/src/services/book_export_collector.rs b/src/services/book_export_collector.rs index 98bebd99..be93e947 100644 --- a/src/services/book_export_collector.rs +++ b/src/services/book_export_collector.rs @@ -11,11 +11,11 @@ use std::collections::HashMap; use std::fmt; use uuid::Uuid; -use crate::db::entities::{book_metadata, books, read_progress}; -use crate::db::repositories::{ +use crate::services::content_filter::ContentFilter; +use codex_db::entities::{book_metadata, books, read_progress}; +use codex_db::repositories::{ GenreRepository, LibraryRepository, ReadProgressRepository, SeriesRepository, TagRepository, }; -use crate::services::content_filter::ContentFilter; // ============================================================================= // BookExportField enum @@ -424,7 +424,7 @@ pub async fn resolve_book_ids( user_id: Uuid, library_ids: &[Uuid], ) -> Result<Vec<Uuid>> { - use crate::db::entities::books::Entity as Books; + use codex_db::entities::books::Entity as Books; use sea_orm::{ColumnTrait, EntityTrait, QueryFilter}; let content_filter = ContentFilter::for_user(db, user_id).await?; @@ -664,7 +664,7 @@ async fn load_book_chunk( db: &DatabaseConnection, ids: &[Uuid], ) -> Result<HashMap<Uuid, books::Model>> { - use crate::db::entities::books::Entity as Books; + use codex_db::entities::books::Entity as Books; use sea_orm::{ColumnTrait, EntityTrait, QueryFilter}; let results = Books::find() @@ -680,7 +680,7 @@ async fn load_metadata_chunk( db: &DatabaseConnection, book_ids: &[Uuid], ) -> Result<HashMap<Uuid, book_metadata::Model>> { - use crate::db::entities::book_metadata::Entity as BookMetadata; + use codex_db::entities::book_metadata::Entity as BookMetadata; use sea_orm::{ColumnTrait, EntityTrait, QueryFilter}; let results = BookMetadata::find() diff --git a/src/services/cleanup_subscriber.rs b/src/services/cleanup_subscriber.rs index 9493e08f..fc774691 100644 --- a/src/services/cleanup_subscriber.rs +++ b/src/services/cleanup_subscriber.rs @@ -8,7 +8,7 @@ use std::sync::Arc; use tokio::sync::broadcast; use tracing::{debug, error, info, warn}; -use crate::db::repositories::TaskRepository; +use codex_db::repositories::TaskRepository; use codex_events::{EntityChangeEvent, EntityEvent, EventBroadcaster}; use codex_models::task::TaskType; @@ -189,8 +189,8 @@ impl CleanupEventSubscriber { #[cfg(test)] mod tests { use super::*; - use crate::db::test_helpers::create_test_db; use chrono::Utc; + use codex_db::test_helpers::create_test_db; use codex_events::EventBroadcaster; use codex_models::task::TaskType; use uuid::Uuid; diff --git a/src/services/content_filter.rs b/src/services/content_filter.rs index 36739dec..a23df28b 100644 --- a/src/services/content_filter.rs +++ b/src/services/content_filter.rs @@ -10,7 +10,7 @@ //! 2. **Whitelist mode** (user has any `allow` grants): User only sees series with allowed tags //! 3. **No grants**: User sees everything (default-open behavior) -use crate::db::repositories::SharingTagRepository; +use codex_db::repositories::SharingTagRepository; use sea_orm::DatabaseConnection; use std::collections::HashSet; use uuid::Uuid; diff --git a/src/services/filter.rs b/src/services/filter.rs index d660f027..a2931e6d 100644 --- a/src/services/filter.rs +++ b/src/services/filter.rs @@ -4,8 +4,8 @@ #![allow(dead_code)] -use crate::db::repositories::{GenreRepository, TagRepository}; use anyhow::Result; +use codex_db::repositories::{GenreRepository, TagRepository}; use codex_models::filter::{ BookCondition, BoolOperator, DateOperator, FieldOperator, NumberOperator, SeriesCondition, UuidOperator, @@ -216,7 +216,7 @@ impl FilterService { operator: &UuidOperator, candidate_ids: Option<&HashSet<Uuid>>, ) -> Result<HashSet<Uuid>> { - use crate::db::entities::series; + use codex_db::entities::series; use sea_orm::{ColumnTrait, EntityTrait, QueryFilter}; match operator { @@ -304,7 +304,7 @@ impl FilterService { .collect()) } else { // Without candidates, we need all series - use crate::db::entities::series; + use codex_db::entities::series; use sea_orm::EntityTrait; let all_series: HashSet<Uuid> = series::Entity::find() @@ -346,7 +346,7 @@ impl FilterService { .cloned() .collect()) } else { - use crate::db::entities::series; + use codex_db::entities::series; use sea_orm::EntityTrait; let all_series: HashSet<Uuid> = series::Entity::find() @@ -396,7 +396,7 @@ impl FilterService { .cloned() .collect()) } else { - use crate::db::entities::series; + use codex_db::entities::series; use sea_orm::EntityTrait; let all_series: HashSet<Uuid> = series::Entity::find() @@ -455,7 +455,7 @@ impl FilterService { .cloned() .collect()) } else { - use crate::db::entities::series; + use codex_db::entities::series; use sea_orm::EntityTrait; let all_series: HashSet<Uuid> = series::Entity::find() @@ -495,7 +495,7 @@ impl FilterService { .cloned() .collect()) } else { - use crate::db::entities::series; + use codex_db::entities::series; use sea_orm::EntityTrait; let all_series: HashSet<Uuid> = series::Entity::find() @@ -543,7 +543,7 @@ impl FilterService { .cloned() .collect()) } else { - use crate::db::entities::series; + use codex_db::entities::series; use sea_orm::EntityTrait; let all_series: HashSet<Uuid> = series::Entity::find() @@ -577,7 +577,7 @@ impl FilterService { operator: &FieldOperator, candidate_ids: Option<&HashSet<Uuid>>, ) -> Result<HashSet<Uuid>> { - use crate::db::repositories::SharingTagRepository; + use codex_db::repositories::SharingTagRepository; match operator { FieldOperator::Is { value } => { @@ -605,7 +605,7 @@ impl FilterService { .cloned() .collect()) } else { - use crate::db::entities::series; + use codex_db::entities::series; use sea_orm::EntityTrait; let all_series: HashSet<Uuid> = series::Entity::find() @@ -646,7 +646,7 @@ impl FilterService { .cloned() .collect()) } else { - use crate::db::entities::series; + use codex_db::entities::series; use sea_orm::EntityTrait; let all_series: HashSet<Uuid> = series::Entity::find() @@ -697,7 +697,7 @@ impl FilterService { .cloned() .collect()) } else { - use crate::db::entities::series; + use codex_db::entities::series; use sea_orm::EntityTrait; let all_series: HashSet<Uuid> = series::Entity::find() @@ -746,7 +746,7 @@ impl FilterService { operator: &BoolOperator, candidate_ids: Option<&HashSet<Uuid>>, ) -> Result<HashSet<Uuid>> { - use crate::db::entities::{books, series_metadata}; + use codex_db::entities::{books, series_metadata}; use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, QuerySelect}; // Get all series with total_volume_count set @@ -827,7 +827,7 @@ impl FilterService { operator: &BoolOperator, candidate_ids: Option<&HashSet<Uuid>>, ) -> Result<HashSet<Uuid>> { - use crate::db::entities::{series, series_external_ids}; + use codex_db::entities::{series, series_external_ids}; use sea_orm::{EntityTrait, QuerySelect}; // Get all series IDs that have at least one external ID @@ -893,7 +893,7 @@ impl FilterService { candidate_ids: Option<&HashSet<Uuid>>, user_id: Option<Uuid>, ) -> Result<HashSet<Uuid>> { - use crate::db::entities::{series, user_series_ratings}; + use codex_db::entities::{series, user_series_ratings}; use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, QuerySelect}; let Some(uid) = user_id else { @@ -977,7 +977,7 @@ impl FilterService { operator: &BoolOperator, candidate_ids: Option<&HashSet<Uuid>>, ) -> Result<HashSet<Uuid>> { - use crate::db::entities::{series, series_tracking}; + use codex_db::entities::{series, series_tracking}; use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, QuerySelect}; // Series with tracking explicitly enabled. @@ -1030,7 +1030,7 @@ impl FilterService { operator: &FieldOperator, candidate_ids: Option<&HashSet<Uuid>>, ) -> Result<HashSet<Uuid>> { - use crate::db::entities::series_metadata; + use codex_db::entities::series_metadata; use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, QuerySelect}; let query = series_metadata::Entity::find(); @@ -1082,7 +1082,7 @@ impl FilterService { operator: &FieldOperator, candidate_ids: Option<&HashSet<Uuid>>, ) -> Result<HashSet<Uuid>> { - use crate::db::entities::series_metadata; + use codex_db::entities::series_metadata; use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, QuerySelect}; let query = series_metadata::Entity::find(); @@ -1113,7 +1113,7 @@ impl FilterService { operator: &FieldOperator, candidate_ids: Option<&HashSet<Uuid>>, ) -> Result<HashSet<Uuid>> { - use crate::db::entities::series_metadata; + use codex_db::entities::series_metadata; use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, QuerySelect}; let query = series_metadata::Entity::find(); @@ -1144,7 +1144,7 @@ impl FilterService { operator: &FieldOperator, candidate_ids: Option<&HashSet<Uuid>>, ) -> Result<HashSet<Uuid>> { - use crate::db::entities::series_metadata; + use codex_db::entities::series_metadata; use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, QuerySelect}; // title is NOT NULL; IsNull always returns empty. @@ -1184,7 +1184,7 @@ impl FilterService { operator: &FieldOperator, candidate_ids: Option<&HashSet<Uuid>>, ) -> Result<HashSet<Uuid>> { - use crate::db::entities::series_metadata; + use codex_db::entities::series_metadata; use sea_orm::{ColumnTrait, Condition, EntityTrait, QueryFilter, QuerySelect}; let query = series_metadata::Entity::find(); @@ -1378,7 +1378,7 @@ impl FilterService { candidate_ids: Option<&HashSet<Uuid>>, user_id: Option<Uuid>, ) -> Result<HashSet<Uuid>> { - use crate::db::entities::{books, read_progress, series}; + use codex_db::entities::{books, read_progress, series}; use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, QuerySelect}; // If no user_id provided, we can't filter by read status @@ -1483,7 +1483,7 @@ impl FilterService { operator: &NumberOperator, candidate_ids: Option<&HashSet<Uuid>>, ) -> Result<HashSet<Uuid>> { - use crate::db::entities::{series, series_metadata}; + use codex_db::entities::{series, series_metadata}; use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, QuerySelect}; // IsNull is special: also include series with no metadata row at all. @@ -1587,7 +1587,7 @@ impl FilterService { operator: &FieldOperator, candidate_ids: Option<&HashSet<Uuid>>, ) -> Result<HashSet<Uuid>> { - use crate::db::entities::series_metadata; + use codex_db::entities::series_metadata; use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, QuerySelect}; let query = series_metadata::Entity::find(); @@ -1621,7 +1621,7 @@ impl FilterService { operator: &FieldOperator, candidate_ids: Option<&HashSet<Uuid>>, ) -> Result<HashSet<Uuid>> { - use crate::db::entities::series; + use codex_db::entities::series; use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, QuerySelect}; // series.path is NOT NULL; IsNull always returns empty. @@ -1656,7 +1656,7 @@ impl FilterService { operator: &DateOperator, candidate_ids: Option<&HashSet<Uuid>>, ) -> Result<HashSet<Uuid>> { - use crate::db::entities::series; + use codex_db::entities::series; use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, QuerySelect}; let query = series::Entity::find(); @@ -1832,7 +1832,7 @@ impl FilterService { operator: &UuidOperator, candidate_ids: Option<&HashSet<Uuid>>, ) -> Result<HashSet<Uuid>> { - use crate::db::entities::{books, series}; + use codex_db::entities::{books, series}; use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, QuerySelect}; match operator { @@ -1914,7 +1914,7 @@ impl FilterService { operator: &UuidOperator, candidate_ids: Option<&HashSet<Uuid>>, ) -> Result<HashSet<Uuid>> { - use crate::db::entities::books; + use codex_db::entities::books; use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, QuerySelect}; match operator { @@ -1979,7 +1979,7 @@ impl FilterService { operator: &FieldOperator, candidate_ids: Option<&HashSet<Uuid>>, ) -> Result<HashSet<Uuid>> { - use crate::db::entities::books; + use codex_db::entities::books; use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, QuerySelect}; // First get series matching the genre condition @@ -2018,7 +2018,7 @@ impl FilterService { operator: &FieldOperator, candidate_ids: Option<&HashSet<Uuid>>, ) -> Result<HashSet<Uuid>> { - use crate::db::entities::books; + use codex_db::entities::books; use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, QuerySelect}; // First get series matching the tag condition @@ -2056,7 +2056,7 @@ impl FilterService { operator: &FieldOperator, candidate_ids: Option<&HashSet<Uuid>>, ) -> Result<HashSet<Uuid>> { - use crate::db::entities::book_metadata; + use codex_db::entities::book_metadata; use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, QuerySelect}; let query = book_metadata::Entity::find(); @@ -2090,7 +2090,7 @@ impl FilterService { operator: &FieldOperator, candidate_ids: Option<&HashSet<Uuid>>, ) -> Result<HashSet<Uuid>> { - use crate::db::entities::book_metadata; + use codex_db::entities::book_metadata; use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, QuerySelect}; let query = book_metadata::Entity::find(); @@ -2128,7 +2128,7 @@ impl FilterService { candidate_ids: Option<&HashSet<Uuid>>, user_id: Option<Uuid>, ) -> Result<HashSet<Uuid>> { - use crate::db::entities::{books, read_progress}; + use codex_db::entities::{books, read_progress}; use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, QuerySelect}; // If no user_id provided, we can't filter by read status @@ -2204,7 +2204,7 @@ impl FilterService { operator: &BoolOperator, candidate_ids: Option<&HashSet<Uuid>>, ) -> Result<HashSet<Uuid>> { - use crate::db::entities::books; + use codex_db::entities::books; use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, QuerySelect}; let query = books::Entity::find(); @@ -2238,7 +2238,7 @@ impl FilterService { operator: &FieldOperator, candidate_ids: Option<&HashSet<Uuid>>, ) -> Result<HashSet<Uuid>> { - use crate::db::entities::book_metadata; + use codex_db::entities::book_metadata; use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, QuerySelect}; let query = book_metadata::Entity::find(); @@ -2295,7 +2295,7 @@ impl FilterService { operator: &FieldOperator, candidate_ids: Option<&HashSet<Uuid>>, ) -> Result<HashSet<Uuid>> { - use crate::db::entities::books; + use codex_db::entities::books; use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, QuerySelect}; // books.path is NOT NULL; IsNull always returns empty. @@ -2354,7 +2354,7 @@ impl FilterService { operator: &FieldOperator, candidate_ids: Option<&HashSet<Uuid>>, ) -> Result<HashSet<Uuid>> { - use crate::db::entities::books; + use codex_db::entities::books; use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, QuerySelect}; let query = books::Entity::find().filter(books::Column::Deleted.eq(false)); @@ -2405,7 +2405,7 @@ impl FilterService { operator: &NumberOperator, candidate_ids: Option<&HashSet<Uuid>>, ) -> Result<HashSet<Uuid>> { - use crate::db::entities::books; + use codex_db::entities::books; use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, QuerySelect}; let query = books::Entity::find().filter(books::Column::Deleted.eq(false)); @@ -2467,7 +2467,7 @@ impl FilterService { operator: &DateOperator, candidate_ids: Option<&HashSet<Uuid>>, ) -> Result<HashSet<Uuid>> { - use crate::db::entities::books; + use codex_db::entities::books; use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, QuerySelect}; let query = books::Entity::find().filter(books::Column::Deleted.eq(false)); diff --git a/src/services/library_jobs/mod.rs b/src/services/library_jobs/mod.rs index cff40409..5a59d257 100644 --- a/src/services/library_jobs/mod.rs +++ b/src/services/library_jobs/mod.rs @@ -2,13 +2,13 @@ //! [`library_jobs`] table. //! //! This module owns the typed shape of the per-job `config` JSON payload. -//! The repository layer ([`crate::db::repositories::LibraryJobRepository`]) +//! The repository layer ([`codex_db::repositories::LibraryJobRepository`]) //! persists strings; the parsing, default-filling, and validation lives here. //! //! Currently the `metadata_refresh` type is supported. Future job types extend //! [`LibraryJobConfig`] without schema changes. //! -//! [`library_jobs`]: crate::db::entities::library_jobs +//! [`library_jobs`]: codex_db::entities::library_jobs pub mod types; pub mod validation; diff --git a/src/services/library_jobs/types.rs b/src/services/library_jobs/types.rs index 6081138d..b9c6fcf2 100644 --- a/src/services/library_jobs/types.rs +++ b/src/services/library_jobs/types.rs @@ -4,7 +4,7 @@ //! column. Currently ships with `metadata_refresh`; future variants extend //! the enum. //! -//! [`library_jobs`]: crate::db::entities::library_jobs +//! [`library_jobs`]: codex_db::entities::library_jobs use serde::{Deserialize, Serialize}; use utoipa::ToSchema; diff --git a/src/services/library_jobs/validation.rs b/src/services/library_jobs/validation.rs index c0c85c8e..b6408a29 100644 --- a/src/services/library_jobs/validation.rs +++ b/src/services/library_jobs/validation.rs @@ -1,5 +1,5 @@ //! Validators for [`super::types::LibraryJobConfig`] and the row-level -//! fields ([`crate::db::entities::library_jobs`] common fields like name and +//! fields ([`codex_db::entities::library_jobs`] common fields like name and //! cron). //! //! Validators are typed as `Result<_, ValidationError>` so callers can map @@ -12,8 +12,8 @@ use thiserror::Error; use std::str::FromStr; -use crate::db::repositories::PluginsRepository; use crate::services::metadata::FieldGroup; +use codex_db::repositories::PluginsRepository; use codex_utils::cron::{validate_cron_expression, validate_timezone}; use super::types::{ diff --git a/src/services/metadata/apply.rs b/src/services/metadata/apply.rs index 5b51f02a..d43b4418 100644 --- a/src/services/metadata/apply.rs +++ b/src/services/metadata/apply.rs @@ -12,15 +12,15 @@ use std::sync::Arc; use tracing::warn; use uuid::Uuid; -use crate::db::entities::SeriesStatus; -use crate::db::entities::plugins::{Model as Plugin, PluginPermission}; -use crate::db::entities::series_metadata::Model as SeriesMetadata; -use crate::db::repositories::{ +use crate::services::ThumbnailService; +use crate::services::plugin::PluginSeriesMetadata; +use codex_db::entities::SeriesStatus; +use codex_db::entities::plugins::{Model as Plugin, PluginPermission}; +use codex_db::entities::series_metadata::Model as SeriesMetadata; +use codex_db::repositories::{ AlternateTitleRepository, ExternalLinkRepository, ExternalRatingRepository, GenreRepository, SeriesExternalIdRepository, SeriesMetadataRepository, TagRepository, }; -use crate::services::ThumbnailService; -use crate::services::plugin::PluginSeriesMetadata; use codex_events::EventBroadcaster; use super::CoverService; diff --git a/src/services/metadata/book_apply.rs b/src/services/metadata/book_apply.rs index 9b4c52b3..85f5568a 100644 --- a/src/services/metadata/book_apply.rs +++ b/src/services/metadata/book_apply.rs @@ -11,11 +11,11 @@ use std::sync::Arc; use tracing::warn; use uuid::Uuid; -use crate::db::entities::book_metadata::Model as BookMetadata; -use crate::db::entities::plugins::{Model as Plugin, PluginPermission}; -use crate::db::repositories::{BookExternalIdRepository, BookMetadataRepository}; use crate::services::ThumbnailService; use crate::services::plugin::protocol::PluginBookMetadata; +use codex_db::entities::book_metadata::Model as BookMetadata; +use codex_db::entities::plugins::{Model as Plugin, PluginPermission}; +use codex_db::repositories::{BookExternalIdRepository, BookMetadataRepository}; use codex_events::EventBroadcaster; use super::CoverService; diff --git a/src/services/metadata/cover.rs b/src/services/metadata/cover.rs index 3588821a..dcf661d9 100644 --- a/src/services/metadata/cover.rs +++ b/src/services/metadata/cover.rs @@ -9,10 +9,10 @@ use std::sync::Arc; use tracing::warn; use uuid::Uuid; -use crate::db::repositories::{ +use crate::services::ThumbnailService; +use codex_db::repositories::{ BookCoversRepository, SeriesCoversRepository, SeriesRepository, TaskRepository, }; -use crate::services::ThumbnailService; use codex_events::{EntityChangeEvent, EntityEvent, EntityType, EventBroadcaster}; use codex_models::task::TaskType; diff --git a/src/services/metadata/preprocessing/context.rs b/src/services/metadata/preprocessing/context.rs index eb8dd93c..0a78ef9f 100644 --- a/src/services/metadata/preprocessing/context.rs +++ b/src/services/metadata/preprocessing/context.rs @@ -502,7 +502,7 @@ impl SeriesContext { use anyhow::Result; use sea_orm::DatabaseConnection; -use crate::db::repositories::{ +use codex_db::repositories::{ AlternateTitleRepository, BookRepository, ExternalLinkRepository, ExternalRatingRepository, GenreRepository, SeriesExternalIdRepository, SeriesMetadataRepository, TagRepository, }; @@ -996,7 +996,7 @@ impl BookContext { // Book Context Builder (async from database) // ============================================================================= -use crate::db::repositories::{ +use codex_db::repositories::{ BookExternalIdRepository, BookExternalLinkRepository, BookMetadataRepository, }; diff --git a/src/services/metadata/refresh_planner.rs b/src/services/metadata/refresh_planner.rs index 801a5a8e..9fe93418 100644 --- a/src/services/metadata/refresh_planner.rs +++ b/src/services/metadata/refresh_planner.rs @@ -15,9 +15,9 @@ use sea_orm::DatabaseConnection; use std::collections::{HashMap, HashSet}; use uuid::Uuid; -use crate::db::entities::plugins::Model as Plugin; -use crate::db::entities::series_external_ids::{self, Model as SeriesExternalId}; -use crate::db::repositories::{PluginsRepository, SeriesExternalIdRepository, SeriesRepository}; +use codex_db::entities::plugins::Model as Plugin; +use codex_db::entities::series_external_ids::{self, Model as SeriesExternalId}; +use codex_db::repositories::{PluginsRepository, SeriesExternalIdRepository, SeriesRepository}; use crate::services::library_jobs::MetadataRefreshJobConfig; @@ -240,12 +240,12 @@ pub fn fields_filter_from_job_config(config: &MetadataRefreshJobConfig) -> Optio #[cfg(test)] mod tests { use super::*; - use crate::db::ScanningStrategy; - use crate::db::entities::plugins::PluginPermission; - use crate::db::repositories::{LibraryRepository, PluginsRepository, SeriesRepository}; - use crate::db::test_helpers::setup_test_db; use crate::services::library_jobs::{MetadataRefreshJobConfig, RefreshScope}; use crate::services::plugin::protocol::PluginScope; + use codex_db::ScanningStrategy; + use codex_db::entities::plugins::PluginPermission; + use codex_db::repositories::{LibraryRepository, PluginsRepository, SeriesRepository}; + use codex_db::test_helpers::setup_test_db; use std::env; use std::sync::Once; diff --git a/src/services/plugin/library.rs b/src/services/plugin/library.rs index 5d8056e2..b7a51aab 100644 --- a/src/services/plugin/library.rs +++ b/src/services/plugin/library.rs @@ -9,15 +9,15 @@ use std::collections::HashMap; use tracing::{debug, warn}; use uuid::Uuid; -use crate::db::entities::SeriesStatus; -use crate::db::repositories::{ +use crate::services::plugin::protocol::{ + UserLibraryEntry, UserLibraryExternalId, UserReadingStatus, +}; +use codex_db::entities::SeriesStatus; +use codex_db::repositories::{ AlternateTitleRepository, BookRepository, GenreRepository, ReadProgressRepository, SeriesExternalIdRepository, SeriesMetadataRepository, SeriesRepository, TagRepository, UserSeriesRatingRepository, }; -use crate::services::plugin::protocol::{ - UserLibraryEntry, UserLibraryExternalId, UserReadingStatus, -}; /// Build the full user library as `Vec<UserLibraryEntry>` for recommendation plugins. /// diff --git a/src/services/plugin/manager.rs b/src/services/plugin/manager.rs index 1c33259d..65bc308c 100644 --- a/src/services/plugin/manager.rs +++ b/src/services/plugin/manager.rs @@ -46,12 +46,12 @@ use tokio::sync::{Mutex, RwLock}; use tracing::{Span, debug, error, field::Empty, info, warn}; use uuid::Uuid; -use crate::db::entities::plugins; -use crate::db::entities::user_plugins; -use crate::db::repositories::{ +use crate::services::PluginMetricsService; +use codex_db::entities::plugins; +use codex_db::entities::user_plugins; +use codex_db::repositories::{ FailureContext, PluginFailuresRepository, PluginsRepository, UserPluginsRepository, }; -use crate::services::PluginMetricsService; use crate::services::user_plugin::token_refresh::{self, RefreshResult}; diff --git a/src/services/plugin/protocol.rs b/src/services/plugin/protocol.rs index 99353e6c..7e921e42 100644 --- a/src/services/plugin/protocol.rs +++ b/src/services/plugin/protocol.rs @@ -840,7 +840,7 @@ pub struct AlternateTitle { } // Re-export SeriesStatus from db entities - this is the canonical source -pub use crate::db::entities::SeriesStatus; +pub use codex_db::entities::SeriesStatus; /// External rating from provider #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/src/services/plugin/releases_handler.rs b/src/services/plugin/releases_handler.rs index 4518ad45..149f97f3 100644 --- a/src/services/plugin/releases_handler.rs +++ b/src/services/plugin/releases_handler.rs @@ -23,17 +23,17 @@ use super::protocol::{ JsonRpcError, JsonRpcRequest, JsonRpcResponse, ReleaseSourceCapability, RequestId, error_codes, methods, }; -use crate::db::entities::release_ledger::state as ledger_state; -use crate::db::entities::release_sources::kind as source_kind; -use crate::db::repositories::{ - NewReleaseSource, ReleaseLedgerRepository, ReleaseSourceRepository, SeriesAliasRepository, - SeriesExternalIdRepository, SeriesRepository, SeriesTrackingRepository, TrackingUpdate, -}; use crate::services::release::auto_ignore::{is_outside_tracking_scope, should_auto_ignore}; use crate::services::release::candidate::ReleaseCandidate; use crate::services::release::languages::{includes, resolve_for_series}; use crate::services::release::matcher::{evaluate, resolve_threshold}; use crate::services::scheduler_handle::SharedSchedulerReconciler; +use codex_db::entities::release_ledger::state as ledger_state; +use codex_db::entities::release_sources::kind as source_kind; +use codex_db::repositories::{ + NewReleaseSource, ReleaseLedgerRepository, ReleaseSourceRepository, SeriesAliasRepository, + SeriesExternalIdRepository, SeriesRepository, SeriesTrackingRepository, TrackingUpdate, +}; /// Default page size for `releases/list_tracked` when the caller doesn't /// specify one. Bounded to keep the response small on first load. @@ -631,7 +631,7 @@ impl ReleasesRequestHandler { async fn advance_latest_known( &self, series_id: Uuid, - tracking_row: Option<&crate::db::entities::series_tracking::Model>, + tracking_row: Option<&codex_db::entities::series_tracking::Model>, candidate_chapter: Option<f64>, candidate_volume: Option<i32>, candidate_language: &str, @@ -762,7 +762,7 @@ impl ReleasesRequestHandler { }; use sea_orm::{ActiveModelTrait, Set}; - let mut active: crate::db::entities::release_sources::ActiveModel = row.into(); + let mut active: codex_db::entities::release_sources::ActiveModel = row.into(); active.etag = Set(params.etag.clone()); active.updated_at = Set(Utc::now()); match active.update(&self.db).await { @@ -1210,16 +1210,16 @@ pub fn is_releases_method(method: &str) -> bool { #[cfg(test)] mod tests { use super::*; - use crate::db::ScanningStrategy; - use crate::db::entities::release_sources::{self, kind}; - use crate::db::repositories::{ + use crate::services::plugin::protocol::ReleaseSourceKind; + use crate::services::release::candidate::{NumericSpan, SeriesMatch}; + use codex_db::ScanningStrategy; + use codex_db::entities::release_sources::{self, kind}; + use codex_db::repositories::{ LibraryRepository, NewReleaseSource, ReleaseSourceRepository, ReleaseSourceUpdate, SeriesAliasRepository, SeriesExternalIdRepository, SeriesRepository, SeriesTrackingRepository, TrackingUpdate, }; - use crate::db::test_helpers::create_test_db; - use crate::services::plugin::protocol::ReleaseSourceKind; - use crate::services::release::candidate::{NumericSpan, SeriesMatch}; + use codex_db::test_helpers::create_test_db; use serde_json::json; use std::sync::Arc; diff --git a/src/services/plugin/storage_handler.rs b/src/services/plugin/storage_handler.rs index c8f97670..ca4997f3 100644 --- a/src/services/plugin/storage_handler.rs +++ b/src/services/plugin/storage_handler.rs @@ -19,7 +19,7 @@ use super::storage::{ StorageGetResponse, StorageKeyEntry, StorageListResponse, StorageSetRequest, StorageSetResponse, }; -use crate::db::repositories::UserPluginDataRepository; +use codex_db::repositories::UserPluginDataRepository; /// Maximum number of storage keys allowed per plugin instance const MAX_KEYS_PER_PLUGIN: usize = 100; @@ -336,11 +336,11 @@ impl WithId for JsonRpcResponse { #[cfg(test)] mod tests { use super::*; - use crate::db::entities::plugins; - use crate::db::entities::users; - use crate::db::repositories::{PluginsRepository, UserPluginsRepository, UserRepository}; - use crate::db::test_helpers::setup_test_db; use crate::services::plugin::protocol::RequestId; + use codex_db::entities::plugins; + use codex_db::entities::users; + use codex_db::repositories::{PluginsRepository, UserPluginsRepository, UserRepository}; + use codex_db::test_helpers::setup_test_db; use serde_json::json; async fn create_test_user(db: &DatabaseConnection) -> users::Model { diff --git a/src/services/read_progress.rs b/src/services/read_progress.rs index 79c95e70..dafcdf15 100644 --- a/src/services/read_progress.rs +++ b/src/services/read_progress.rs @@ -13,7 +13,7 @@ use tokio_util::sync::CancellationToken; use tracing::{debug, error, warn}; use uuid::Uuid; -use crate::db::repositories::ReadProgressRepository; +use codex_db::repositories::ReadProgressRepository; /// Maximum number of entries before forcing a flush const MAX_BUFFER_SIZE: usize = 100; @@ -212,12 +212,12 @@ impl ReadProgressService { #[cfg(test)] mod tests { use super::*; - use crate::db::entities::{books, users}; - use crate::db::repositories::{ + use chrono::Utc; + use codex_db::entities::{books, users}; + use codex_db::repositories::{ BookRepository, LibraryRepository, ReadProgressRepository, SeriesRepository, UserRepository, }; - use crate::db::test_helpers::setup_test_db; - use chrono::Utc; + use codex_db::test_helpers::setup_test_db; use codex_models::ScanningStrategy; use codex_utils::password; use std::time::Duration; diff --git a/src/services/refresh_token.rs b/src/services/refresh_token.rs index 2613f0d3..4e17e8f5 100644 --- a/src/services/refresh_token.rs +++ b/src/services/refresh_token.rs @@ -17,8 +17,8 @@ use sha2::{Digest, Sha256}; use thiserror::Error; use uuid::Uuid; -use crate::db::entities::refresh_tokens; -use crate::db::repositories::{NewRefreshToken, RefreshTokenRepository}; +use codex_db::entities::refresh_tokens; +use codex_db::repositories::{NewRefreshToken, RefreshTokenRepository}; /// 32 random bytes -> 43-character URL-safe base64 (no padding). const TOKEN_BYTES: usize = 32; @@ -211,10 +211,10 @@ fn hex_encode(bytes: &[u8]) -> String { #[cfg(test)] mod tests { use super::*; - use crate::db::Database; - use crate::db::entities::users; - use crate::db::repositories::UserRepository; use codex_config::{DatabaseConfig, DatabaseType, SQLiteConfig}; + use codex_db::Database; + use codex_db::entities::users; + use codex_db::repositories::UserRepository; use std::collections::HashMap; use tempfile::TempDir; diff --git a/src/services/release/auto_ignore.rs b/src/services/release/auto_ignore.rs index c6a318d8..0f455da9 100644 --- a/src/services/release/auto_ignore.rs +++ b/src/services/release/auto_ignore.rs @@ -9,7 +9,7 @@ //! Direct matches only. We do not infer chapter ownership from owned //! volumes (chapter→volume mapping is unreliable upstream) or vice versa. //! -//! Inputs come from [`crate::db::repositories::SeriesRepository::get_owned_release_keys_for_series`]: +//! Inputs come from [`codex_db::repositories::SeriesRepository::get_owned_release_keys_for_series`]: //! the set of `(volume, chapter)` pairs derived from book metadata, plus //! a count fallback used only when no book in the series has any volume //! metadata. diff --git a/src/services/release/languages.rs b/src/services/release/languages.rs index ad9659f1..bc90df9b 100644 --- a/src/services/release/languages.rs +++ b/src/services/release/languages.rs @@ -13,7 +13,7 @@ use anyhow::Result; use sea_orm::DatabaseConnection; use serde_json::Value; -use crate::db::repositories::SettingsRepository; +use codex_db::repositories::SettingsRepository; /// Settings key for the server-wide default language list. pub const SERVER_DEFAULT_LANGUAGES_KEY: &str = "release_tracking.default_languages"; diff --git a/src/services/release/matcher.rs b/src/services/release/matcher.rs index 71af8711..3cdaba82 100644 --- a/src/services/release/matcher.rs +++ b/src/services/release/matcher.rs @@ -8,14 +8,14 @@ //! `confidence_threshold_override`). //! //! The actual ledger write goes through -//! [`crate::db::repositories::ReleaseLedgerRepository::record`], which is +//! [`codex_db::repositories::ReleaseLedgerRepository::record`], which is //! itself idempotent on `(source_id, external_release_id)` and `info_hash`. use chrono::Utc; use uuid::Uuid; use super::candidate::{CandidateReject, MAX_FUTURE_SKEW_S, ReleaseCandidate}; -use crate::db::repositories::NewReleaseEntry; +use codex_db::repositories::NewReleaseEntry; /// Default confidence threshold (`0.7`). pub const DEFAULT_CONFIDENCE_THRESHOLD: f64 = 0.7; diff --git a/src/services/release/seed.rs b/src/services/release/seed.rs index a88ce2e9..2fe1082b 100644 --- a/src/services/release/seed.rs +++ b/src/services/release/seed.rs @@ -45,8 +45,8 @@ use anyhow::{Context, Result}; use sea_orm::DatabaseConnection; use uuid::Uuid; -use crate::db::entities::series_aliases::alias_source; -use crate::db::repositories::{ +use codex_db::entities::series_aliases::alias_source; +use codex_db::repositories::{ AlternateTitleRepository, SeriesAliasRepository, SeriesMetadataRepository, SeriesRepository, SeriesTrackingRepository, TrackingUpdate, }; @@ -223,13 +223,13 @@ mod tests { use chrono::Utc; use sea_orm::{ActiveModelTrait, Set}; - use crate::db::ScanningStrategy; - use crate::db::entities::{book_metadata, books}; - use crate::db::repositories::{ + use codex_db::ScanningStrategy; + use codex_db::entities::{book_metadata, books}; + use codex_db::repositories::{ AlternateTitleRepository, BookMetadataRepository, BookRepository, LibraryRepository, SeriesAliasRepository, SeriesRepository, SeriesTrackingRepository, }; - use crate::db::test_helpers::create_test_db; + use codex_db::test_helpers::create_test_db; #[test] fn is_latin_alias_accepts_latin_strings() { diff --git a/src/services/release/tracking_toggle.rs b/src/services/release/tracking_toggle.rs index 60d5e97a..c8dcd53d 100644 --- a/src/services/release/tracking_toggle.rs +++ b/src/services/release/tracking_toggle.rs @@ -13,8 +13,8 @@ use serde::{Deserialize, Serialize}; use std::sync::Arc; use uuid::Uuid; -use crate::db::repositories::{SeriesRepository, SeriesTrackingRepository, TrackingUpdate}; use crate::services::release::seed::seed_tracking_for_series; +use codex_db::repositories::{SeriesRepository, SeriesTrackingRepository, TrackingUpdate}; use codex_events::{EntityChangeEvent, EntityEvent, EventBroadcaster}; /// Discrete outcomes for a single-series toggle attempt. @@ -180,12 +180,12 @@ fn errored(series_id: Uuid, reason: impl Into<String>) -> ToggleResult { mod tests { use super::*; - use crate::db::ScanningStrategy; - use crate::db::repositories::{ + use codex_db::ScanningStrategy; + use codex_db::repositories::{ LibraryRepository, SeriesAliasRepository, SeriesRepository, SeriesTrackingRepository, TrackingUpdate, }; - use crate::db::test_helpers::create_test_db; + use codex_db::test_helpers::create_test_db; async fn make_series(db: &DatabaseConnection, library_id: Uuid, name: &str) -> Uuid { SeriesRepository::create(db, library_id, name, None) diff --git a/src/services/release/upstream_gap.rs b/src/services/release/upstream_gap.rs index f3a93de2..3beae68f 100644 --- a/src/services/release/upstream_gap.rs +++ b/src/services/release/upstream_gap.rs @@ -11,8 +11,8 @@ //! language publication facts are not the same category as //! translation/scanlation releases (which the MangaUpdates plugin handles). -use crate::db::entities::series_external_ids::Model as SeriesExternalId; -use crate::db::entities::series_tracking::Model as SeriesTrackingRow; +use codex_db::entities::series_external_ids::Model as SeriesExternalId; +use codex_db::entities::series_tracking::Model as SeriesTrackingRow; /// Computed gap between upstream publication and local content for a series. /// diff --git a/src/services/series_export_collector.rs b/src/services/series_export_collector.rs index 8108785e..c8f63d10 100644 --- a/src/services/series_export_collector.rs +++ b/src/services/series_export_collector.rs @@ -11,13 +11,13 @@ use std::collections::HashMap; use std::fmt; use uuid::Uuid; -use crate::db::entities::series; -use crate::db::repositories::{ +use crate::services::content_filter::ContentFilter; +use codex_db::entities::series; +use codex_db::repositories::{ AlternateTitleRepository, BookRepository, ExternalRatingRepository, GenreRepository, LibraryRepository, SeriesMetadataRepository, SeriesRepository, TagRepository, UserSeriesRatingRepository, }; -use crate::services::content_filter::ContentFilter; // ============================================================================= // ExportField enum @@ -749,11 +749,11 @@ async fn load_series_chunk( db: &DatabaseConnection, ids: &[Uuid], ) -> Result<HashMap<Uuid, series::Model>> { - use crate::db::entities::series::Entity as Series; + use codex_db::entities::series::Entity as Series; use sea_orm::{ColumnTrait, EntityTrait, QueryFilter}; let results = Series::find() - .filter(crate::db::entities::series::Column::Id.is_in(ids.to_vec())) + .filter(codex_db::entities::series::Column::Id.is_in(ids.to_vec())) .all(db) .await?; diff --git a/src/services/settings.rs b/src/services/settings.rs index 590b7e4e..e369458f 100644 --- a/src/services/settings.rs +++ b/src/services/settings.rs @@ -4,9 +4,9 @@ #![allow(dead_code)] -use crate::db::repositories::SettingsRepository; use anyhow::Result; use chrono::{DateTime, Utc}; +use codex_db::repositories::SettingsRepository; use sea_orm::DatabaseConnection; use serde::de::DeserializeOwned; use std::collections::HashMap; @@ -194,7 +194,7 @@ impl SettingsService { #[cfg(test)] mod tests { use super::*; - use crate::db::test_helpers::setup_test_db; + use codex_db::test_helpers::setup_test_db; #[tokio::test] async fn test_settings_service_get() { diff --git a/src/services/task_listener.rs b/src/services/task_listener.rs index 6a9f714e..3c6ee424 100644 --- a/src/services/task_listener.rs +++ b/src/services/task_listener.rs @@ -8,10 +8,10 @@ //! result. This service replays those events when tasks complete, bridging //! events across process boundaries. -use crate::db::repositories::TaskRepository; use anyhow::{Context, Result}; use chrono::TimeZone; use chrono::Utc; +use codex_db::repositories::TaskRepository; use codex_events::{ EntityChangeEvent, EventBroadcaster, RecordedEvent, TaskProgressEvent, TaskStatus, }; diff --git a/src/services/task_metrics.rs b/src/services/task_metrics.rs index c656c698..1d2429af 100644 --- a/src/services/task_metrics.rs +++ b/src/services/task_metrics.rs @@ -9,8 +9,8 @@ use tokio_util::sync::CancellationToken; use tracing::{debug, error}; use uuid::Uuid; -use crate::db::repositories::task_metrics::{TaskCompletionData, TaskMetricsRepository}; use crate::services::SettingsService; +use codex_db::repositories::task_metrics::{TaskCompletionData, TaskMetricsRepository}; /// Number of recent completions to keep for percentile calculation const MAX_RECENT_COMPLETIONS: usize = 1000; @@ -802,7 +802,7 @@ pub struct TaskMetricsDataPoint { #[cfg(test)] mod tests { use super::*; - use crate::db::test_helpers::setup_test_db; + use codex_db::test_helpers::setup_test_db; async fn create_test_service() -> TaskMetricsService { let db = setup_test_db().await; diff --git a/src/services/thumbnail.rs b/src/services/thumbnail.rs index ba56a66b..5c5a2843 100644 --- a/src/services/thumbnail.rs +++ b/src/services/thumbnail.rs @@ -19,9 +19,9 @@ use tokio_util::io::ReaderStream; use tracing::{debug, error, info, warn}; use uuid::Uuid; -use crate::db::entities::books; -use crate::db::repositories::{BookRepository, SeriesRepository, SettingsRepository}; use codex_config::FilesConfig; +use codex_db::entities::books; +use codex_db::repositories::{BookRepository, SeriesRepository, SettingsRepository}; use codex_events::{EntityChangeEvent, EntityEvent, EntityType, EventBroadcaster}; // ============================================================================ diff --git a/src/services/user_plugin/token_refresh.rs b/src/services/user_plugin/token_refresh.rs index 3f5b2401..7018a310 100644 --- a/src/services/user_plugin/token_refresh.rs +++ b/src/services/user_plugin/token_refresh.rs @@ -8,9 +8,9 @@ use chrono::{Duration, Utc}; use sea_orm::DatabaseConnection; use tracing::{debug, info, warn}; -use crate::db::entities::user_plugins; -use crate::db::repositories::UserPluginsRepository; use crate::services::plugin::protocol::OAuthConfig; +use codex_db::entities::user_plugins; +use codex_db::repositories::UserPluginsRepository; use super::oauth::OAuthTokenResponse; diff --git a/src/tasks/handlers/analyze_book.rs b/src/tasks/handlers/analyze_book.rs index 12520869..04555529 100644 --- a/src/tasks/handlers/analyze_book.rs +++ b/src/tasks/handlers/analyze_book.rs @@ -4,11 +4,11 @@ use serde_json::json; use std::sync::Arc; use tracing::{error, info, warn}; -use crate::db::entities::tasks; -use crate::db::repositories::BookRepository; use crate::scanner::analyze_book; use crate::tasks::handlers::TaskHandler; use crate::tasks::types::TaskResult; +use codex_db::entities::tasks; +use codex_db::repositories::BookRepository; use codex_events::{EventBroadcaster, TaskProgressEvent}; pub struct AnalyzeBookHandler; diff --git a/src/tasks/handlers/analyze_series.rs b/src/tasks/handlers/analyze_series.rs index a7c55d52..52d8f45e 100644 --- a/src/tasks/handlers/analyze_series.rs +++ b/src/tasks/handlers/analyze_series.rs @@ -4,10 +4,10 @@ use serde_json::json; use std::sync::Arc; use tracing::{error, info}; -use crate::db::entities::tasks; -use crate::db::repositories::{BookRepository, TaskRepository}; use crate::tasks::handlers::TaskHandler; use crate::tasks::types::{TaskResult, TaskType}; +use codex_db::entities::tasks; +use codex_db::repositories::{BookRepository, TaskRepository}; use codex_events::{EventBroadcaster, TaskProgressEvent}; pub struct AnalyzeSeriesHandler; diff --git a/src/tasks/handlers/backfill_tracking.rs b/src/tasks/handlers/backfill_tracking.rs index 3f3825ad..625d992c 100644 --- a/src/tasks/handlers/backfill_tracking.rs +++ b/src/tasks/handlers/backfill_tracking.rs @@ -17,11 +17,11 @@ use std::sync::Arc; use tracing::{info, warn}; use uuid::Uuid; -use crate::db::entities::tasks; -use crate::db::repositories::SeriesRepository; use crate::services::release::seed::{SeedReport, seed_tracking_for_series}; use crate::tasks::handlers::TaskHandler; use crate::tasks::types::TaskResult; +use codex_db::entities::tasks; +use codex_db::repositories::SeriesRepository; use codex_events::EventBroadcaster; pub struct BackfillTrackingFromMetadataHandler; @@ -150,12 +150,12 @@ async fn resolve_series_scope( #[cfg(test)] mod tests { use super::*; - use crate::db::ScanningStrategy; - use crate::db::repositories::{ + use codex_db::ScanningStrategy; + use codex_db::repositories::{ AlternateTitleRepository, LibraryRepository, SeriesAliasRepository, SeriesRepository, SeriesTrackingRepository, }; - use crate::db::test_helpers::create_test_db; + use codex_db::test_helpers::create_test_db; async fn make_series( db: &DatabaseConnection, diff --git a/src/tasks/handlers/bulk_track_for_releases.rs b/src/tasks/handlers/bulk_track_for_releases.rs index 24793e20..2d8eeef4 100644 --- a/src/tasks/handlers/bulk_track_for_releases.rs +++ b/src/tasks/handlers/bulk_track_for_releases.rs @@ -17,12 +17,12 @@ use std::sync::Arc; use tracing::{info, warn}; use uuid::Uuid; -use crate::db::entities::tasks; use crate::services::release::tracking_toggle::{ ToggleOutcome, ToggleResult, track_one_series, untrack_one_series, }; use crate::tasks::handlers::TaskHandler; use crate::tasks::types::TaskResult; +use codex_db::entities::tasks; use codex_events::{EventBroadcaster, TaskProgressEvent}; pub struct BulkTrackForReleasesHandler; @@ -194,13 +194,13 @@ fn emit_progress( mod tests { use super::*; - use crate::db::ScanningStrategy; - use crate::db::repositories::{ + use crate::tasks::types::TaskType; + use codex_db::ScanningStrategy; + use codex_db::repositories::{ LibraryRepository, SeriesAliasRepository, SeriesRepository, SeriesTrackingRepository, TaskRepository, TrackingUpdate, }; - use crate::db::test_helpers::create_test_db; - use crate::tasks::types::TaskType; + use codex_db::test_helpers::create_test_db; async fn fetch_task(db: &DatabaseConnection, id: Uuid) -> tasks::Model { TaskRepository::get_by_id(db, id) diff --git a/src/tasks/handlers/cleanup_book_files.rs b/src/tasks/handlers/cleanup_book_files.rs index dded8009..2b1a63e7 100644 --- a/src/tasks/handlers/cleanup_book_files.rs +++ b/src/tasks/handlers/cleanup_book_files.rs @@ -12,11 +12,11 @@ use std::path::PathBuf; use std::sync::Arc; use tracing::{debug, info, warn}; -use crate::db::entities::tasks; use crate::services::{FileCleanupService, ThumbnailService}; use crate::tasks::handlers::TaskHandler; use crate::tasks::types::TaskResult; use codex_config::FilesConfig; +use codex_db::entities::tasks; use codex_events::EventBroadcaster; /// Handler for cleaning up book files after deletion diff --git a/src/tasks/handlers/cleanup_orphaned_files.rs b/src/tasks/handlers/cleanup_orphaned_files.rs index a4182a16..8f752fa7 100644 --- a/src/tasks/handlers/cleanup_orphaned_files.rs +++ b/src/tasks/handlers/cleanup_orphaned_files.rs @@ -10,12 +10,12 @@ use serde_json::json; use std::sync::Arc; use tracing::{debug, info}; -use crate::db::entities::tasks; -use crate::db::repositories::{BookRepository, SeriesRepository}; use crate::services::{CleanupStats, FileCleanupService, OrphanedFileType}; use crate::tasks::handlers::TaskHandler; use crate::tasks::types::TaskResult; use codex_config::FilesConfig; +use codex_db::entities::tasks; +use codex_db::repositories::{BookRepository, SeriesRepository}; use codex_events::EventBroadcaster; /// Handler for cleaning up orphaned files diff --git a/src/tasks/handlers/cleanup_pdf_cache.rs b/src/tasks/handlers/cleanup_pdf_cache.rs index 3257b432..c8409f3b 100644 --- a/src/tasks/handlers/cleanup_pdf_cache.rs +++ b/src/tasks/handlers/cleanup_pdf_cache.rs @@ -9,10 +9,10 @@ use serde_json::json; use std::sync::Arc; use tracing::info; -use crate::db::entities::tasks; use crate::services::{PdfPageCache, SettingsService}; use crate::tasks::handlers::TaskHandler; use crate::tasks::types::TaskResult; +use codex_db::entities::tasks; use codex_events::EventBroadcaster; /// Handler for cleaning up old PDF cache pages diff --git a/src/tasks/handlers/cleanup_plugin_data.rs b/src/tasks/handlers/cleanup_plugin_data.rs index 90ee155b..50e1ac10 100644 --- a/src/tasks/handlers/cleanup_plugin_data.rs +++ b/src/tasks/handlers/cleanup_plugin_data.rs @@ -11,11 +11,11 @@ use serde_json::json; use std::sync::Arc; use tracing::info; -use crate::db::entities::tasks; -use crate::db::repositories::UserPluginDataRepository; use crate::services::user_plugin::OAuthStateManager; use crate::tasks::handlers::TaskHandler; use crate::tasks::types::TaskResult; +use codex_db::entities::tasks; +use codex_db::repositories::UserPluginDataRepository; use codex_events::EventBroadcaster; /// Handler for cleaning up expired plugin storage data and OAuth state diff --git a/src/tasks/handlers/cleanup_refresh_tokens.rs b/src/tasks/handlers/cleanup_refresh_tokens.rs index 37844ef0..51be2498 100644 --- a/src/tasks/handlers/cleanup_refresh_tokens.rs +++ b/src/tasks/handlers/cleanup_refresh_tokens.rs @@ -11,10 +11,10 @@ use serde_json::json; use std::sync::Arc; use tracing::info; -use crate::db::entities::tasks; -use crate::db::repositories::RefreshTokenRepository; use crate::tasks::handlers::TaskHandler; use crate::tasks::types::TaskResult; +use codex_db::entities::tasks; +use codex_db::repositories::RefreshTokenRepository; use codex_events::EventBroadcaster; /// Days a revoked refresh-token row sticks around before cleanup deletes it. @@ -57,11 +57,11 @@ impl TaskHandler for CleanupRefreshTokensHandler { #[cfg(test)] mod tests { use super::*; - use crate::db::Database; - use crate::db::entities::users; - use crate::db::repositories::{NewRefreshToken, UserRepository}; use chrono::{Duration, Utc}; use codex_config::{DatabaseConfig, DatabaseType, SQLiteConfig}; + use codex_db::Database; + use codex_db::entities::users; + use codex_db::repositories::{NewRefreshToken, UserRepository}; use std::collections::HashMap; use tempfile::TempDir; use uuid::Uuid; diff --git a/src/tasks/handlers/cleanup_series_exports.rs b/src/tasks/handlers/cleanup_series_exports.rs index 992d8021..4418933c 100644 --- a/src/tasks/handlers/cleanup_series_exports.rs +++ b/src/tasks/handlers/cleanup_series_exports.rs @@ -11,12 +11,12 @@ use serde_json::json; use std::sync::Arc; use tracing::{info, warn}; -use crate::db::entities::tasks; -use crate::db::repositories::SeriesExportRepository; use crate::services::SettingsService; use crate::services::export_storage::ExportStorage; use crate::tasks::handlers::TaskHandler; use crate::tasks::types::TaskResult; +use codex_db::entities::tasks; +use codex_db::repositories::SeriesExportRepository; use codex_events::{EventBroadcaster, TaskProgressEvent}; /// Default global storage cap: 2 GiB @@ -150,8 +150,8 @@ impl TaskHandler for CleanupSeriesExportsHandler { // Get ALL completed exports ordered oldest first, evict until under cap // We use list_expired with a far-future date to get all completed, then sort let all_completed = { - use crate::db::entities::series_exports; - use crate::db::entities::series_exports::Entity as SeriesExport; + use codex_db::entities::series_exports; + use codex_db::entities::series_exports::Entity as SeriesExport; use sea_orm::*; SeriesExport::find() diff --git a/src/tasks/handlers/cleanup_series_files.rs b/src/tasks/handlers/cleanup_series_files.rs index 1001217c..2855f4bd 100644 --- a/src/tasks/handlers/cleanup_series_files.rs +++ b/src/tasks/handlers/cleanup_series_files.rs @@ -9,11 +9,11 @@ use serde_json::json; use std::sync::Arc; use tracing::{debug, info, warn}; -use crate::db::entities::tasks; use crate::services::FileCleanupService; use crate::tasks::handlers::TaskHandler; use crate::tasks::types::TaskResult; use codex_config::FilesConfig; +use codex_db::entities::tasks; use codex_events::EventBroadcaster; /// Handler for cleaning up series files after deletion diff --git a/src/tasks/handlers/export_series.rs b/src/tasks/handlers/export_series.rs index d25cdb84..43e44b83 100644 --- a/src/tasks/handlers/export_series.rs +++ b/src/tasks/handlers/export_series.rs @@ -13,8 +13,6 @@ use std::sync::Arc; use tracing::{error, info, warn}; use uuid::Uuid; -use crate::db::entities::tasks; -use crate::db::repositories::SeriesExportRepository; use crate::services::SettingsService; use crate::services::book_export_collector::{self, BookExportField, BookExportRow}; use crate::services::export_storage::ExportStorage; @@ -22,6 +20,8 @@ use crate::services::series_export_collector::{self, ExportField, SeriesExportRo use crate::services::series_export_writer; use crate::tasks::handlers::TaskHandler; use crate::tasks::types::TaskResult; +use codex_db::entities::tasks; +use codex_db::repositories::SeriesExportRepository; use codex_events::{EventBroadcaster, TaskProgressEvent}; /// Default maximum number of completed exports kept per user. @@ -151,7 +151,7 @@ impl ExportSeriesHandler { task_id: Uuid, export_id: Uuid, user_id: Uuid, - export: &crate::db::entities::series_exports::Model, + export: &codex_db::entities::series_exports::Model, db: &DatabaseConnection, event_broadcaster: Option<&Arc<EventBroadcaster>>, started_at: chrono::DateTime<Utc>, diff --git a/src/tasks/handlers/find_duplicates.rs b/src/tasks/handlers/find_duplicates.rs index 5715909e..f96855b5 100644 --- a/src/tasks/handlers/find_duplicates.rs +++ b/src/tasks/handlers/find_duplicates.rs @@ -3,11 +3,11 @@ use sea_orm::DatabaseConnection; use std::sync::Arc; use tracing::{info, warn}; -use crate::db::entities::tasks; -use crate::db::repositories::{ +use crate::tasks::types::TaskResult; +use codex_db::entities::tasks; +use codex_db::repositories::{ BookDuplicatesRepository, SeriesDuplicatesRepository, SettingsRepository, }; -use crate::tasks::types::TaskResult; use codex_events::EventBroadcaster; use super::TaskHandler; diff --git a/src/tasks/handlers/generate_series_thumbnail.rs b/src/tasks/handlers/generate_series_thumbnail.rs index 09d620a6..9e1c7ea7 100644 --- a/src/tasks/handlers/generate_series_thumbnail.rs +++ b/src/tasks/handlers/generate_series_thumbnail.rs @@ -8,11 +8,11 @@ use sea_orm::DatabaseConnection; use std::sync::Arc; use tracing::{debug, info, warn}; -use crate::db::entities::tasks; -use crate::db::repositories::{BookRepository, SeriesCoversRepository, SeriesRepository}; use crate::services::ThumbnailService; use crate::tasks::handlers::TaskHandler; use crate::tasks::types::TaskResult; +use codex_db::entities::tasks; +use codex_db::repositories::{BookRepository, SeriesCoversRepository, SeriesRepository}; use codex_events::{EntityChangeEvent, EntityEvent, EntityType, EventBroadcaster}; pub struct GenerateSeriesThumbnailHandler { diff --git a/src/tasks/handlers/generate_series_thumbnails.rs b/src/tasks/handlers/generate_series_thumbnails.rs index eb4c0915..35568f9e 100644 --- a/src/tasks/handlers/generate_series_thumbnails.rs +++ b/src/tasks/handlers/generate_series_thumbnails.rs @@ -9,11 +9,11 @@ use sea_orm::DatabaseConnection; use std::sync::Arc; use tracing::{debug, info, warn}; -use crate::db::entities::tasks; -use crate::db::repositories::{SeriesRepository, TaskRepository}; use crate::services::ThumbnailService; use crate::tasks::handlers::TaskHandler; use crate::tasks::types::{TaskResult, TaskType}; +use codex_db::entities::tasks; +use codex_db::repositories::{SeriesRepository, TaskRepository}; use codex_events::{EventBroadcaster, TaskProgressEvent}; pub struct GenerateSeriesThumbnailsHandler { diff --git a/src/tasks/handlers/generate_thumbnail.rs b/src/tasks/handlers/generate_thumbnail.rs index 85387e3c..82745df2 100644 --- a/src/tasks/handlers/generate_thumbnail.rs +++ b/src/tasks/handlers/generate_thumbnail.rs @@ -3,12 +3,12 @@ use sea_orm::DatabaseConnection; use std::sync::Arc; use tracing::{debug, error, info, warn}; -use crate::db::entities::book_error::{BookError, BookErrorType}; -use crate::db::entities::tasks; -use crate::db::repositories::{BookRepository, SeriesRepository, TaskRepository}; use crate::services::ThumbnailService; use crate::tasks::handlers::TaskHandler; use crate::tasks::types::{TaskResult, TaskType}; +use codex_db::entities::book_error::{BookError, BookErrorType}; +use codex_db::entities::tasks; +use codex_db::repositories::{BookRepository, SeriesRepository, TaskRepository}; use codex_events::{EntityChangeEvent, EntityEvent, EntityType, EventBroadcaster}; pub struct GenerateThumbnailHandler { diff --git a/src/tasks/handlers/generate_thumbnails.rs b/src/tasks/handlers/generate_thumbnails.rs index bd2e9233..6eacdd28 100644 --- a/src/tasks/handlers/generate_thumbnails.rs +++ b/src/tasks/handlers/generate_thumbnails.rs @@ -3,11 +3,11 @@ use sea_orm::DatabaseConnection; use std::sync::Arc; use tracing::{debug, info, warn}; -use crate::db::entities::tasks; -use crate::db::repositories::{BookRepository, TaskRepository}; use crate::services::ThumbnailService; use crate::tasks::handlers::TaskHandler; use crate::tasks::types::{TaskResult, TaskType}; +use codex_db::entities::tasks; +use codex_db::repositories::{BookRepository, TaskRepository}; use codex_events::{EventBroadcaster, TaskProgressEvent}; pub struct GenerateThumbnailsHandler { diff --git a/src/tasks/handlers/mod.rs b/src/tasks/handlers/mod.rs index 1c865acd..d1d611d2 100644 --- a/src/tasks/handlers/mod.rs +++ b/src/tasks/handlers/mod.rs @@ -2,8 +2,8 @@ use anyhow::Result; use sea_orm::DatabaseConnection; use std::sync::Arc; -use crate::db::entities::tasks; use crate::tasks::types::TaskResult; +use codex_db::entities::tasks; use codex_events::EventBroadcaster; pub mod analyze_book; diff --git a/src/tasks/handlers/plugin_auto_match.rs b/src/tasks/handlers/plugin_auto_match.rs index 603b825d..7f4fd214 100644 --- a/src/tasks/handlers/plugin_auto_match.rs +++ b/src/tasks/handlers/plugin_auto_match.rs @@ -19,11 +19,6 @@ use std::sync::Arc; use tracing::{debug, error, info, warn}; use uuid::Uuid; -use crate::db::entities::tasks; -use crate::db::repositories::{ - BookExternalIdRepository, BookMetadataRepository, BookRepository, LibraryRepository, - PluginsRepository, SeriesExternalIdRepository, SeriesMetadataRepository, SeriesRepository, -}; use crate::services::ThumbnailService; use crate::services::metadata::preprocessing::{ AutoMatchConditions, PreprocessingRule, SeriesContext, SeriesContextBuilder, apply_rules, @@ -39,6 +34,11 @@ use crate::services::plugin::{PluginManager, PluginManagerError}; use crate::services::settings::SettingsService; use crate::tasks::handlers::TaskHandler; use crate::tasks::types::TaskResult; +use codex_db::entities::tasks; +use codex_db::repositories::{ + BookExternalIdRepository, BookMetadataRepository, BookRepository, LibraryRepository, + PluginsRepository, SeriesExternalIdRepository, SeriesMetadataRepository, SeriesRepository, +}; use codex_events::{EntityChangeEvent, EntityEvent, EventBroadcaster, TaskProgressEvent}; /// Settings key for the auto-match confidence threshold @@ -128,7 +128,7 @@ impl PluginAutoMatchHandler { series_id: Uuid, library_id: Uuid, plugin_id: Uuid, - plugin: &crate::db::entities::plugins::Model, + plugin: &codex_db::entities::plugins::Model, plugin_rules: &[PreprocessingRule], library_rules: &[PreprocessingRule], ) -> Result<TaskResult> { diff --git a/src/tasks/handlers/poll_release_source.rs b/src/tasks/handlers/poll_release_source.rs index 99ddab6a..f0595b6b 100644 --- a/src/tasks/handlers/poll_release_source.rs +++ b/src/tasks/handlers/poll_release_source.rs @@ -33,13 +33,6 @@ use std::time::Duration; use tracing::{debug, error, info, warn}; use uuid::Uuid; -use crate::db::entities::release_ledger::state as ledger_state; -use crate::db::entities::release_sources::plugin_id as source_plugin_id; -use crate::db::entities::tasks; -use crate::db::repositories::{ - NewReleaseEntry, PluginsRepository, ReleaseLedgerRepository, ReleaseSourceRepository, - SeriesRepository, SeriesTrackingRepository, -}; use crate::services::SettingsService; use crate::services::plugin::PluginManager; use crate::services::plugin::handle::PluginError; @@ -49,6 +42,13 @@ use crate::services::release::backoff::{HostBackoff, is_backoff_status}; use crate::services::release::matcher::{evaluate, resolve_threshold}; use crate::tasks::handlers::TaskHandler; use crate::tasks::types::TaskResult; +use codex_db::entities::release_ledger::state as ledger_state; +use codex_db::entities::release_sources::plugin_id as source_plugin_id; +use codex_db::entities::tasks; +use codex_db::repositories::{ + NewReleaseEntry, PluginsRepository, ReleaseLedgerRepository, ReleaseSourceRepository, + SeriesRepository, SeriesTrackingRepository, +}; use codex_events::{EntityChangeEvent, EventBroadcaster}; /// Default plugin task timeout in seconds (5 minutes — same as user_plugin_sync). @@ -633,7 +633,7 @@ pub(crate) fn build_poll_summary( /// the ledger row is the source of truth, the SSE event is a UX nicety. pub(crate) fn emit_release_announced( broadcaster: &EventBroadcaster, - row: &crate::db::entities::release_ledger::Model, + row: &codex_db::entities::release_ledger::Model, plugin_id: &str, series_title: String, ) { @@ -703,7 +703,7 @@ async fn resolve_initial_state( /// Looks in `config.url`, `config.feed_url`, and `config.base_url` in that /// order; falls back to the plugin name (so all sources on the same plugin /// share a backoff key). -fn derive_url_hint(source: &crate::db::entities::release_sources::Model) -> String { +fn derive_url_hint(source: &codex_db::entities::release_sources::Model) -> String { if let Some(cfg) = source.config.as_ref() { for key in ["url", "feedUrl", "feed_url", "baseUrl", "base_url"] { if let Some(v) = cfg.get(key).and_then(|v| v.as_str()) @@ -718,7 +718,7 @@ fn derive_url_hint(source: &crate::db::entities::release_sources::Model) -> Stri async fn record_error( db: &DatabaseConnection, - source: &crate::db::entities::release_sources::Model, + source: &codex_db::entities::release_sources::Model, event_broadcaster: Option<&Arc<EventBroadcaster>>, message: &str, ) { @@ -745,13 +745,13 @@ async fn record_error( #[cfg(test)] mod tests { use super::*; - use crate::db::ScanningStrategy; - use crate::db::entities::release_sources::kind; - use crate::db::repositories::{ + use codex_db::ScanningStrategy; + use codex_db::entities::release_sources::kind; + use codex_db::repositories::{ LibraryRepository, NewReleaseSource, ReleaseSourceRepository, SeriesRepository, SeriesTrackingRepository, TrackingUpdate, }; - use crate::db::test_helpers::create_test_db; + use codex_db::test_helpers::create_test_db; use codex_events::EntityEvent; @@ -762,7 +762,7 @@ mod tests { let broadcaster = EventBroadcaster::new(8); let mut rx = broadcaster.subscribe(); - let row = crate::db::entities::release_ledger::Model { + let row = codex_db::entities::release_ledger::Model { id: Uuid::new_v4(), series_id: Uuid::new_v4(), source_id: Uuid::new_v4(), @@ -822,7 +822,7 @@ mod tests { #[test] fn emit_release_announced_tolerates_no_subscribers() { let broadcaster = EventBroadcaster::new(8); - let row = crate::db::entities::release_ledger::Model { + let row = codex_db::entities::release_ledger::Model { id: Uuid::new_v4(), series_id: Uuid::new_v4(), source_id: Uuid::new_v4(), @@ -867,8 +867,8 @@ mod tests { assert_eq!(derive_url_hint(&model), "https://example.com/x"); } - fn make_model() -> crate::db::entities::release_sources::Model { - crate::db::entities::release_sources::Model { + fn make_model() -> codex_db::entities::release_sources::Model { + codex_db::entities::release_sources::Model { id: Uuid::new_v4(), plugin_id: "release-nyaa".to_string(), plugin_uuid: None, diff --git a/src/tasks/handlers/purge_deleted.rs b/src/tasks/handlers/purge_deleted.rs index 35b81996..f9b6fd63 100644 --- a/src/tasks/handlers/purge_deleted.rs +++ b/src/tasks/handlers/purge_deleted.rs @@ -4,10 +4,10 @@ use serde_json::json; use std::sync::Arc; use tracing::{error, info}; -use crate::db::entities::tasks; -use crate::db::repositories::BookRepository; use crate::tasks::handlers::TaskHandler; use crate::tasks::types::TaskResult; +use codex_db::entities::tasks; +use codex_db::repositories::BookRepository; use codex_events::EventBroadcaster; pub struct PurgeDeletedHandler; diff --git a/src/tasks/handlers/refresh_library_metadata.rs b/src/tasks/handlers/refresh_library_metadata.rs index 9442ffbe..adcab6d2 100644 --- a/src/tasks/handlers/refresh_library_metadata.rs +++ b/src/tasks/handlers/refresh_library_metadata.rs @@ -11,7 +11,7 @@ //! time so a job that somehow persisted with a deferred scope short-circuits //! with a clear failure status. //! -//! [`library_jobs`]: crate::db::entities::library_jobs +//! [`library_jobs`]: codex_db::entities::library_jobs use anyhow::{Context, Result}; use sea_orm::DatabaseConnection; @@ -20,11 +20,6 @@ use std::sync::Arc; use std::time::Duration; use tracing::{debug, error, info, warn}; -use crate::db::entities::tasks; -use crate::db::repositories::{ - LibraryJobRepository, LibraryRepository, PluginsRepository, RecordRunStatus, - SeriesExternalIdRepository, SeriesMetadataRepository, SeriesRepository, -}; use crate::services::ThumbnailService; use crate::services::library_jobs::{LibraryJobConfig, RefreshScope, parse_job_config}; use crate::services::metadata::refresh_planner::{ @@ -36,6 +31,11 @@ use crate::services::plugin::PluginManager; use crate::services::plugin::protocol::{MetadataGetParams, MetadataMatchParams}; use crate::tasks::handlers::TaskHandler; use crate::tasks::types::TaskResult; +use codex_db::entities::tasks; +use codex_db::repositories::{ + LibraryJobRepository, LibraryRepository, PluginsRepository, RecordRunStatus, + SeriesExternalIdRepository, SeriesMetadataRepository, SeriesRepository, +}; use codex_events::{EntityChangeEvent, EntityEvent, EventBroadcaster, TaskProgressEvent}; /// Soft cap to keep one job's refresh from monopolizing the worker. @@ -168,7 +168,7 @@ impl TaskHandler for RefreshLibraryMetadataHandler { // SeriesOnly requires `metadata_provider`. if let Some(plugin_name) = cfg.provider.strip_prefix("plugin:") && let Ok(Some(plugin)) = - crate::db::repositories::PluginsRepository::get_by_name(db, plugin_name).await + codex_db::repositories::PluginsRepository::get_by_name(db, plugin_name).await && let Some(manifest) = plugin.cached_manifest() && !manifest.capabilities.can_provide_series_metadata() { @@ -533,19 +533,19 @@ async fn rematch_external_id( #[cfg(test)] mod tests { use super::*; - use crate::db::ScanningStrategy; - use crate::db::entities::plugins::PluginPermission; - use crate::db::repositories::{ - CreateLibraryJobParams, LibraryJobRepository, LibraryRepository, PluginsRepository, - SeriesRepository, TaskRepository, - }; - use crate::db::test_helpers::setup_test_db; use crate::services::library_jobs::{ LibraryJobConfig, MetadataRefreshJobConfig, RefreshScope, parse_job_config, }; use crate::services::plugin::PluginManager; use crate::services::plugin::protocol::PluginScope; use crate::tasks::types::TaskType; + use codex_db::ScanningStrategy; + use codex_db::entities::plugins::PluginPermission; + use codex_db::repositories::{ + CreateLibraryJobParams, LibraryJobRepository, LibraryRepository, PluginsRepository, + SeriesRepository, TaskRepository, + }; + use codex_db::test_helpers::setup_test_db; use std::env; use std::sync::Once; diff --git a/src/tasks/handlers/renumber_series.rs b/src/tasks/handlers/renumber_series.rs index 9b85d40e..fc72d605 100644 --- a/src/tasks/handlers/renumber_series.rs +++ b/src/tasks/handlers/renumber_series.rs @@ -11,10 +11,10 @@ use std::sync::Arc; use tracing::{debug, info, warn}; use uuid::Uuid; -use crate::db::entities::tasks; -use crate::db::repositories::{SeriesRepository, TaskRepository}; use crate::tasks::handlers::TaskHandler; use crate::tasks::types::{TaskResult, TaskType}; +use codex_db::entities::tasks; +use codex_db::repositories::{SeriesRepository, TaskRepository}; use codex_events::{EntityChangeEvent, EntityEvent, EventBroadcaster, TaskProgressEvent}; // ============================================================================= diff --git a/src/tasks/handlers/reprocess_series_titles.rs b/src/tasks/handlers/reprocess_series_titles.rs index 4bf8f41d..a28ee3fd 100644 --- a/src/tasks/handlers/reprocess_series_titles.rs +++ b/src/tasks/handlers/reprocess_series_titles.rs @@ -10,13 +10,13 @@ use std::sync::Arc; use tracing::{debug, info, warn}; use uuid::Uuid; -use crate::db::entities::{series_metadata, tasks}; -use crate::db::repositories::{ - LibraryRepository, SeriesMetadataRepository, SeriesRepository, TaskRepository, -}; use crate::services::metadata::preprocessing::apply_rules; use crate::tasks::handlers::TaskHandler; use crate::tasks::types::{TaskResult, TaskType}; +use codex_db::entities::{series_metadata, tasks}; +use codex_db::repositories::{ + LibraryRepository, SeriesMetadataRepository, SeriesRepository, TaskRepository, +}; use codex_events::{EntityChangeEvent, EntityEvent, EventBroadcaster, TaskProgressEvent}; // ============================================================================= diff --git a/src/tasks/handlers/scan_library.rs b/src/tasks/handlers/scan_library.rs index 3aa26d42..f17aa6c8 100644 --- a/src/tasks/handlers/scan_library.rs +++ b/src/tasks/handlers/scan_library.rs @@ -4,15 +4,15 @@ use serde_json::json; use std::sync::Arc; use tracing::{debug, error, info, warn}; -use crate::db::entities::tasks; -use crate::db::repositories::{ - BookRepository, LibraryRepository, PluginsRepository, SeriesRepository, TaskRepository, -}; use crate::scanner::{ScanMode, ScanningConfig, scan_library}; use crate::services::plugin::protocol::PluginScope; use crate::services::settings::SettingsService; use crate::tasks::handlers::TaskHandler; use crate::tasks::types::{TaskResult, TaskType}; +use codex_db::entities::tasks; +use codex_db::repositories::{ + BookRepository, LibraryRepository, PluginsRepository, SeriesRepository, TaskRepository, +}; use codex_events::EventBroadcaster; /// Settings key for enabling post-scan auto-match diff --git a/src/tasks/handlers/user_plugin_recommendation_dismiss.rs b/src/tasks/handlers/user_plugin_recommendation_dismiss.rs index deb23af0..d7a979d8 100644 --- a/src/tasks/handlers/user_plugin_recommendation_dismiss.rs +++ b/src/tasks/handlers/user_plugin_recommendation_dismiss.rs @@ -11,7 +11,6 @@ use std::time::Duration; use tracing::{debug, info, warn}; use uuid::Uuid; -use crate::db::entities::tasks; use crate::services::SettingsService; use crate::services::plugin::PluginManager; use crate::services::plugin::protocol::methods; @@ -20,6 +19,7 @@ use crate::services::plugin::recommendations::{ }; use crate::tasks::handlers::TaskHandler; use crate::tasks::types::TaskResult; +use codex_db::entities::tasks; use codex_events::EventBroadcaster; /// Default plugin task timeout in seconds (5 minutes) diff --git a/src/tasks/handlers/user_plugin_recommendations.rs b/src/tasks/handlers/user_plugin_recommendations.rs index b91198b3..7548c4b9 100644 --- a/src/tasks/handlers/user_plugin_recommendations.rs +++ b/src/tasks/handlers/user_plugin_recommendations.rs @@ -14,8 +14,6 @@ use std::time::Duration; use tracing::{debug, info, warn}; use uuid::Uuid; -use crate::db::entities::tasks; -use crate::db::repositories::{PluginsRepository, UserPluginDataRepository, UserPluginsRepository}; use crate::services::SettingsService; use crate::services::plugin::PluginManager; use crate::services::plugin::library::build_user_library; @@ -27,6 +25,8 @@ use crate::services::plugin::recommendations::{ }; use crate::tasks::handlers::TaskHandler; use crate::tasks::types::TaskResult; +use codex_db::entities::tasks; +use codex_db::repositories::{PluginsRepository, UserPluginDataRepository, UserPluginsRepository}; use codex_events::EventBroadcaster; /// Default plugin task timeout in seconds (5 minutes) diff --git a/src/tasks/handlers/user_plugin_sync/mod.rs b/src/tasks/handlers/user_plugin_sync/mod.rs index c805b992..b0e11ed3 100644 --- a/src/tasks/handlers/user_plugin_sync/mod.rs +++ b/src/tasks/handlers/user_plugin_sync/mod.rs @@ -25,8 +25,6 @@ use std::time::Duration; use tracing::{debug, error, info, warn}; use uuid::Uuid; -use crate::db::entities::tasks; -use crate::db::repositories::{UserPluginDataRepository, UserPluginsRepository}; use crate::services::SettingsService; use crate::services::plugin::PluginManager; use crate::services::plugin::protocol::methods; @@ -35,6 +33,8 @@ use crate::services::plugin::sync::{ }; use crate::tasks::handlers::TaskHandler; use crate::tasks::types::TaskResult; +use codex_db::entities::tasks; +use codex_db::repositories::{UserPluginDataRepository, UserPluginsRepository}; use codex_events::{EventBroadcaster, TaskProgressEvent}; pub(crate) use settings::CodexSyncSettings; diff --git a/src/tasks/handlers/user_plugin_sync/pull.rs b/src/tasks/handlers/user_plugin_sync/pull.rs index 36bbe796..4a563509 100644 --- a/src/tasks/handlers/user_plugin_sync/pull.rs +++ b/src/tasks/handlers/user_plugin_sync/pull.rs @@ -6,10 +6,10 @@ use std::collections::HashMap; use tracing::{debug, warn}; use uuid::Uuid; -use crate::db::repositories::{ +use crate::services::plugin::sync::{SyncEntry, SyncReadingStatus}; +use codex_db::repositories::{ BookRepository, ReadProgressRepository, SeriesExternalIdRepository, UserSeriesRatingRepository, }; -use crate::services::plugin::sync::{SyncEntry, SyncReadingStatus}; /// Match pulled sync entries to Codex series using external IDs and apply /// reading progress. @@ -87,7 +87,7 @@ pub(crate) async fn match_and_apply_pulled_entries( }; // 4. Batch-fetch existing ratings if sync_ratings is enabled (1 query instead of N) - let existing_ratings: HashMap<Uuid, crate::db::entities::user_series_ratings::Model> = + let existing_ratings: HashMap<Uuid, codex_db::entities::user_series_ratings::Model> = if sync_ratings { match UserSeriesRatingRepository::get_all_for_user(db, user_id).await { Ok(ratings) => ratings.into_iter().map(|r| (r.series_id, r)).collect(), @@ -188,8 +188,8 @@ async fn apply_pulled_entry( series_id: Uuid, entry: &SyncEntry, task_id: Uuid, - books_map: &HashMap<Uuid, Vec<crate::db::entities::books::Model>>, - progress_map: &HashMap<Uuid, crate::db::entities::read_progress::Model>, + books_map: &HashMap<Uuid, Vec<codex_db::entities::books::Model>>, + progress_map: &HashMap<Uuid, codex_db::entities::read_progress::Model>, ) -> u32 { let books = match books_map.get(&series_id) { Some(b) if !b.is_empty() => b, @@ -204,7 +204,7 @@ async fn apply_pulled_entry( .unwrap_or(0); // Determine which books to mark as read - let books_to_mark: &[crate::db::entities::books::Model] = + let books_to_mark: &[codex_db::entities::books::Model] = if entry.status == SyncReadingStatus::Completed { // Mark all books as read books diff --git a/src/tasks/handlers/user_plugin_sync/push.rs b/src/tasks/handlers/user_plugin_sync/push.rs index a37c6f80..eebe1be9 100644 --- a/src/tasks/handlers/user_plugin_sync/push.rs +++ b/src/tasks/handlers/user_plugin_sync/push.rs @@ -6,11 +6,11 @@ use std::collections::{HashMap, HashSet}; use tracing::{debug, warn}; use uuid::Uuid; -use crate::db::repositories::{ +use crate::services::plugin::sync::{SyncEntry, SyncProgress, SyncReadingStatus}; +use codex_db::repositories::{ BookRepository, ReadProgressRepository, SeriesExternalIdRepository, SeriesMetadataRepository, UserSeriesRatingRepository, }; -use crate::services::plugin::sync::{SyncEntry, SyncProgress, SyncReadingStatus}; use super::settings::CodexSyncSettings; @@ -100,7 +100,7 @@ pub(crate) async fn build_push_entries( }; // 5. Batch-fetch all user ratings (1 query — already batched) - let ratings_map: HashMap<Uuid, crate::db::entities::user_series_ratings::Model> = + let ratings_map: HashMap<Uuid, codex_db::entities::user_series_ratings::Model> = if settings.sync_ratings { match UserSeriesRatingRepository::get_all_for_user(db, user_id).await { Ok(ratings) => ratings.into_iter().map(|r| (r.series_id, r)).collect(), @@ -393,7 +393,7 @@ async fn build_unmatched_entries( } }; - let ratings_map: HashMap<Uuid, crate::db::entities::user_series_ratings::Model> = + let ratings_map: HashMap<Uuid, codex_db::entities::user_series_ratings::Model> = if settings.sync_ratings { match UserSeriesRatingRepository::get_all_for_user(db, user_id).await { Ok(ratings) => ratings.into_iter().map(|r| (r.series_id, r)).collect(), diff --git a/src/tasks/handlers/user_plugin_sync/tests.rs b/src/tasks/handlers/user_plugin_sync/tests.rs index c605e001..79eb6dab 100644 --- a/src/tasks/handlers/user_plugin_sync/tests.rs +++ b/src/tasks/handlers/user_plugin_sync/tests.rs @@ -1,13 +1,13 @@ use super::*; -use crate::db::ScanningStrategy; -use crate::db::entities::{books, users}; -use crate::db::repositories::{ +use crate::services::plugin::sync::{SyncEntry, SyncProgress, SyncReadingStatus}; +use chrono::Utc; +use codex_db::ScanningStrategy; +use codex_db::entities::{books, users}; +use codex_db::repositories::{ BookRepository, LibraryRepository, ReadProgressRepository, SeriesExternalIdRepository, SeriesMetadataRepository, SeriesRepository, UserRepository, UserSeriesRatingRepository, }; -use crate::db::test_helpers::create_test_db; -use crate::services::plugin::sync::{SyncEntry, SyncProgress, SyncReadingStatus}; -use chrono::Utc; +use codex_db::test_helpers::create_test_db; /// Helper to create a test user in the database async fn create_test_user(db: &sea_orm::DatabaseConnection) -> users::Model { diff --git a/src/tasks/worker.rs b/src/tasks/worker.rs index b3374078..bdd54a8a 100644 --- a/src/tasks/worker.rs +++ b/src/tasks/worker.rs @@ -16,7 +16,6 @@ use tokio::time::sleep; use tracing::{debug, error, info, warn}; use uuid::Uuid; -use crate::db::repositories::TaskRepository; use crate::services::PdfPageCache; use crate::services::export_storage::ExportStorage; use crate::services::plugin::PluginManager; @@ -37,6 +36,7 @@ use crate::tasks::handlers::{ UserPluginSyncHandler, }; use codex_config::FilesConfig; +use codex_db::repositories::TaskRepository; use codex_events::{EventBroadcaster, RecordedEvent, TaskProgressEvent}; /// RAII guard that increments the OTel in-flight task gauge on creation and @@ -807,7 +807,7 @@ impl TaskWorker { /// Complete a task successfully, storing result and recorded events async fn complete_task( &self, - task: &crate::db::entities::tasks::Model, + task: &codex_db::entities::tasks::Model, task_result: crate::tasks::types::TaskResult, started_at: chrono::DateTime<Utc>, recorded_events: Option<Vec<RecordedEvent>>, @@ -898,7 +898,7 @@ impl TaskWorker { /// a retry attempt. Otherwise, the task is marked as failed normally. async fn fail_task( &self, - task: &crate::db::entities::tasks::Model, + task: &codex_db::entities::tasks::Model, error: anyhow::Error, started_at: chrono::DateTime<Utc>, ) -> Result<()> { @@ -1015,10 +1015,10 @@ impl TaskWorker { #[cfg(test)] mod tests { use super::*; - use crate::db::repositories::TaskRepository; - use crate::db::test_helpers::create_test_db; use crate::tasks::handlers::TaskHandler; use crate::tasks::types::{TaskResult, TaskType}; + use codex_db::repositories::TaskRepository; + use codex_db::test_helpers::create_test_db; use codex_events::{EntityChangeEvent, EntityEvent, EntityType}; /// Stub handler that returns whatever `TaskResult` it was constructed with. @@ -1031,7 +1031,7 @@ mod tests { impl TaskHandler for StubHandler { fn handle<'a>( &'a self, - _task: &'a crate::db::entities::tasks::Model, + _task: &'a codex_db::entities::tasks::Model, _db: &'a sea_orm::DatabaseConnection, _event_broadcaster: Option<&'a Arc<EventBroadcaster>>, ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<TaskResult>> + Send + 'a>> From b5195709691917ee83bbc1f96aae928bf6b1186e Mon Sep 17 00:00:00 2001 From: Sylvain Cau <ashdevfr@gmail.com> Date: Sat, 23 May 2026 18:49:59 -0700 Subject: [PATCH 05/14] refactor(workspace): extract codex-services crate Moves the entire services layer (auth, plugin runtime, metadata pipeline, release tracking, thumbnail/PDF caches, file cleanup, OIDC, email, etc.) out of the root crate into a new sibling workspace member. Two cycles surfaced during extraction and got resolved before the move: - services -> observability::metrics. The metrics module (OTel meter instruments for plugin/task lifecycle events) takes only primitives and is conceptually a service concern. Moved into codex-services as `services::metrics`, gated behind a new `observability` feature on the crate that forwards to the opentelemetry deps. The root crate's observability module re-exports `pub use codex_services::metrics` so existing `crate::observability::metrics::*` call paths in tasks and the API HTTP middleware keep resolving with no churn at those sites. - services -> tasks::handlers::poll_release_source::lookup_series_title. The function was a pure series-title DB lookup that the services-side reverse-RPC release handler called into. Moved to `services::release::announce::lookup_series_title`; the tasks-side caller now uses the codex-services path. Removes the only real upward edge from services to tasks. Also folds in a small layering drift fix: scanner imported `crate::tasks::types::TaskType`, but `TaskType` has lived in `codex-models::task` since the earlier layering cleanup, so the import is re-pointed there. Lets scanner sit cleanly below tasks once it's extracted. Root crate keeps `pub use codex_services as services` so integration tests using `codex::services::*` paths resolve unchanged. Plugin/task metric tests that need `opentelemetry_sdk` and `tracing_subscriber` move into codex-services dev-dependencies. --- Cargo.lock | 51 ++++++++ Cargo.toml | 6 +- crates/codex-services/Cargo.toml | 117 ++++++++++++++++++ .../codex-services/src}/auth_tracking.rs | 0 .../src}/book_export_collector.rs | 2 +- .../codex-services/src}/cleanup_subscriber.rs | 0 .../codex-services/src}/content_filter.rs | 0 .../codex-services/src}/email.rs | 0 .../codex-services/src}/export_storage.rs | 0 .../codex-services/src}/file_cleanup.rs | 0 .../codex-services/src}/filter.rs | 0 .../src}/inflight_thumbnails.rs | 0 .../codex-services/src/lib.rs | 9 ++ .../codex-services/src}/library_jobs/mod.rs | 0 .../codex-services/src}/library_jobs/types.rs | 0 .../src}/library_jobs/validation.rs | 2 +- .../codex-services/src}/metadata/apply.rs | 4 +- .../src}/metadata/book_apply.rs | 4 +- .../codex-services/src}/metadata/cover.rs | 2 +- .../src}/metadata/field_groups.rs | 6 +- .../codex-services/src}/metadata/mod.rs | 0 .../src}/metadata/preprocessing/conditions.rs | 0 .../src}/metadata/preprocessing/context.rs | 0 .../src}/metadata/preprocessing/mod.rs | 0 .../src}/metadata/preprocessing/rules.rs | 0 .../src}/metadata/preprocessing/templates.rs | 0 .../src}/metadata/preprocessing/types.rs | 0 .../src}/metadata/refresh_planner.rs | 6 +- .../codex-services/src}/metrics.rs | 4 +- .../codex-services/src}/metrics_stub.rs | 0 .../codex-services/src}/oidc.rs | 0 .../codex-services/src}/pdf_cache.rs | 0 .../codex-services/src}/pdf_handle_cache.rs | 0 .../src}/pdf_handle_cache_subscriber.rs | 2 +- .../codex-services/src}/plugin/handle.rs | 4 +- .../codex-services/src}/plugin/health.rs | 0 .../codex-services/src}/plugin/library.rs | 4 +- .../codex-services/src}/plugin/manager.rs | 15 +-- .../codex-services/src}/plugin/mod.rs | 0 .../codex-services/src}/plugin/permissions.rs | 0 .../codex-services/src}/plugin/process.rs | 0 .../codex-services/src}/plugin/protocol.rs | 2 +- .../src}/plugin/recommendations.rs | 0 .../src}/plugin/releases_handler.rs | 26 ++-- .../codex-services/src}/plugin/rpc.rs | 0 .../codex-services/src}/plugin/secrets.rs | 2 +- .../codex-services/src}/plugin/storage.rs | 0 .../src}/plugin/storage_handler.rs | 2 +- .../codex-services/src}/plugin/sync.rs | 0 .../src}/plugin_file_storage.rs | 0 .../codex-services/src}/plugin_metrics.rs | 6 +- .../codex-services/src}/rate_limiter.rs | 0 .../codex-services/src}/read_progress.rs | 0 .../codex-services/src}/refresh_token.rs | 0 crates/codex-services/src/release/announce.rs | 29 +++++ .../src}/release/auto_ignore.rs | 0 .../codex-services/src}/release/backoff.rs | 0 .../codex-services/src}/release/candidate.rs | 0 .../codex-services/src}/release/languages.rs | 0 .../codex-services/src}/release/matcher.rs | 8 +- .../codex-services/src}/release/mod.rs | 1 + .../codex-services/src}/release/schedule.rs | 2 +- .../codex-services/src}/release/seed.rs | 0 .../src}/release/tracking_toggle.rs | 2 +- .../src}/release/upstream_gap.rs | 0 .../codex-services/src}/scheduler_handle.rs | 0 .../src}/series_export_collector.rs | 2 +- .../src}/series_export_writer.rs | 0 .../codex-services/src}/settings.rs | 0 .../codex-services/src}/task_listener.rs | 0 .../codex-services/src}/task_metrics.rs | 9 +- .../codex-services/src}/thumbnail.rs | 0 .../codex-services/src}/user_plugin/mod.rs | 0 .../codex-services/src}/user_plugin/oauth.rs | 2 +- .../src}/user_plugin/token_refresh.rs | 2 +- src/api/extractors/auth.rs | 36 +++--- src/api/extractors/mod.rs | 4 +- src/api/middleware/rate_limit.rs | 2 +- src/api/routes/v1/dto/book.rs | 4 +- src/api/routes/v1/dto/cleanup.rs | 6 +- src/api/routes/v1/dto/library_jobs.rs | 2 +- src/api/routes/v1/dto/pdf_cache.rs | 16 +-- src/api/routes/v1/dto/plugin_storage.rs | 8 +- src/api/routes/v1/dto/plugins.rs | 28 ++--- src/api/routes/v1/dto/release.rs | 4 +- src/api/routes/v1/dto/series.rs | 4 +- src/api/routes/v1/handlers/auth.rs | 2 +- src/api/routes/v1/handlers/books.rs | 2 +- src/api/routes/v1/handlers/cleanup.rs | 2 +- src/api/routes/v1/handlers/library_jobs.rs | 14 +-- src/api/routes/v1/handlers/plugin_actions.rs | 28 ++--- src/api/routes/v1/handlers/plugins.rs | 6 +- src/api/routes/v1/handlers/recommendations.rs | 18 +-- src/api/routes/v1/handlers/releases.rs | 10 +- src/api/routes/v1/handlers/series.rs | 8 +- src/api/routes/v1/handlers/series_exports.rs | 4 +- src/api/routes/v1/handlers/task_queue.rs | 4 +- src/api/routes/v1/handlers/tracking.rs | 2 +- src/api/routes/v1/handlers/user_plugins.rs | 4 +- src/commands/common.rs | 16 +-- src/commands/seed.rs | 2 +- src/commands/serve.rs | 46 +++---- src/commands/worker.rs | 18 +-- src/lib.rs | 6 +- src/main.rs | 1 - src/observability/mod.rs | 9 +- src/scanner/analyzer_queue.rs | 2 +- src/scanner/library_scanner.rs | 16 +-- src/scheduler/mod.rs | 8 +- src/scheduler/release_sources.rs | 4 +- src/tasks/error.rs | 6 +- src/tasks/handlers/backfill_tracking.rs | 2 +- src/tasks/handlers/bulk_track_for_releases.rs | 8 +- src/tasks/handlers/cleanup_book_files.rs | 2 +- src/tasks/handlers/cleanup_orphaned_files.rs | 2 +- src/tasks/handlers/cleanup_pdf_cache.rs | 2 +- src/tasks/handlers/cleanup_plugin_data.rs | 4 +- src/tasks/handlers/cleanup_series_exports.rs | 4 +- src/tasks/handlers/cleanup_series_files.rs | 2 +- src/tasks/handlers/export_series.rs | 10 +- .../handlers/generate_series_thumbnail.rs | 2 +- .../handlers/generate_series_thumbnails.rs | 2 +- src/tasks/handlers/generate_thumbnail.rs | 2 +- src/tasks/handlers/generate_thumbnails.rs | 2 +- src/tasks/handlers/plugin_auto_match.rs | 28 ++--- src/tasks/handlers/poll_release_source.rs | 44 +++---- .../handlers/refresh_library_metadata.rs | 28 ++--- src/tasks/handlers/reprocess_series_titles.rs | 2 +- src/tasks/handlers/scan_library.rs | 8 +- .../user_plugin_recommendation_dismiss.rs | 12 +- .../handlers/user_plugin_recommendations.rs | 18 +-- src/tasks/handlers/user_plugin_sync/mod.rs | 16 +-- src/tasks/handlers/user_plugin_sync/pull.rs | 2 +- src/tasks/handlers/user_plugin_sync/push.rs | 2 +- src/tasks/handlers/user_plugin_sync/tests.rs | 2 +- src/tasks/worker.rs | 20 +-- 136 files changed, 543 insertions(+), 368 deletions(-) create mode 100644 crates/codex-services/Cargo.toml rename {src/services => crates/codex-services/src}/auth_tracking.rs (100%) rename {src/services => crates/codex-services/src}/book_export_collector.rs (99%) rename {src/services => crates/codex-services/src}/cleanup_subscriber.rs (100%) rename {src/services => crates/codex-services/src}/content_filter.rs (100%) rename {src/services => crates/codex-services/src}/email.rs (100%) rename {src/services => crates/codex-services/src}/export_storage.rs (100%) rename {src/services => crates/codex-services/src}/file_cleanup.rs (100%) rename {src/services => crates/codex-services/src}/filter.rs (100%) rename {src/services => crates/codex-services/src}/inflight_thumbnails.rs (100%) rename src/services/mod.rs => crates/codex-services/src/lib.rs (87%) rename {src/services => crates/codex-services/src}/library_jobs/mod.rs (100%) rename {src/services => crates/codex-services/src}/library_jobs/types.rs (100%) rename {src/services => crates/codex-services/src}/library_jobs/validation.rs (99%) rename {src/services => crates/codex-services/src}/metadata/apply.rs (99%) rename {src/services => crates/codex-services/src}/metadata/book_apply.rs (99%) rename {src/services => crates/codex-services/src}/metadata/cover.rs (99%) rename {src/services => crates/codex-services/src}/metadata/field_groups.rs (98%) rename {src/services => crates/codex-services/src}/metadata/mod.rs (100%) rename {src/services => crates/codex-services/src}/metadata/preprocessing/conditions.rs (100%) rename {src/services => crates/codex-services/src}/metadata/preprocessing/context.rs (100%) rename {src/services => crates/codex-services/src}/metadata/preprocessing/mod.rs (100%) rename {src/services => crates/codex-services/src}/metadata/preprocessing/rules.rs (100%) rename {src/services => crates/codex-services/src}/metadata/preprocessing/templates.rs (100%) rename {src/services => crates/codex-services/src}/metadata/preprocessing/types.rs (100%) rename {src/services => crates/codex-services/src}/metadata/refresh_planner.rs (98%) rename {src/observability => crates/codex-services/src}/metrics.rs (99%) rename {src/observability => crates/codex-services/src}/metrics_stub.rs (100%) rename {src/services => crates/codex-services/src}/oidc.rs (100%) rename {src/services => crates/codex-services/src}/pdf_cache.rs (100%) rename {src/services => crates/codex-services/src}/pdf_handle_cache.rs (100%) rename {src/services => crates/codex-services/src}/pdf_handle_cache_subscriber.rs (99%) rename {src/services => crates/codex-services/src}/plugin/handle.rs (99%) rename {src/services => crates/codex-services/src}/plugin/health.rs (100%) rename {src/services => crates/codex-services/src}/plugin/library.rs (98%) rename {src/services => crates/codex-services/src}/plugin/manager.rs (99%) rename {src/services => crates/codex-services/src}/plugin/mod.rs (100%) rename {src/services => crates/codex-services/src}/plugin/permissions.rs (100%) rename {src/services => crates/codex-services/src}/plugin/process.rs (100%) rename {src/services => crates/codex-services/src}/plugin/protocol.rs (99%) rename {src/services => crates/codex-services/src}/plugin/recommendations.rs (100%) rename {src/services => crates/codex-services/src}/plugin/releases_handler.rs (99%) rename {src/services => crates/codex-services/src}/plugin/rpc.rs (100%) rename {src/services => crates/codex-services/src}/plugin/secrets.rs (98%) rename {src/services => crates/codex-services/src}/plugin/storage.rs (100%) rename {src/services => crates/codex-services/src}/plugin/storage_handler.rs (99%) rename {src/services => crates/codex-services/src}/plugin/sync.rs (100%) rename {src/services => crates/codex-services/src}/plugin_file_storage.rs (100%) rename {src/services => crates/codex-services/src}/plugin_metrics.rs (98%) rename {src/services => crates/codex-services/src}/rate_limiter.rs (100%) rename {src/services => crates/codex-services/src}/read_progress.rs (100%) rename {src/services => crates/codex-services/src}/refresh_token.rs (100%) create mode 100644 crates/codex-services/src/release/announce.rs rename {src/services => crates/codex-services/src}/release/auto_ignore.rs (100%) rename {src/services => crates/codex-services/src}/release/backoff.rs (100%) rename {src/services => crates/codex-services/src}/release/candidate.rs (100%) rename {src/services => crates/codex-services/src}/release/languages.rs (100%) rename {src/services => crates/codex-services/src}/release/matcher.rs (97%) rename {src/services => crates/codex-services/src}/release/mod.rs (98%) rename {src/services => crates/codex-services/src}/release/schedule.rs (98%) rename {src/services => crates/codex-services/src}/release/seed.rs (100%) rename {src/services => crates/codex-services/src}/release/tracking_toggle.rs (99%) rename {src/services => crates/codex-services/src}/release/upstream_gap.rs (100%) rename {src/services => crates/codex-services/src}/scheduler_handle.rs (100%) rename {src/services => crates/codex-services/src}/series_export_collector.rs (99%) rename {src/services => crates/codex-services/src}/series_export_writer.rs (100%) rename {src/services => crates/codex-services/src}/settings.rs (100%) rename {src/services => crates/codex-services/src}/task_listener.rs (100%) rename {src/services => crates/codex-services/src}/task_metrics.rs (99%) rename {src/services => crates/codex-services/src}/thumbnail.rs (100%) rename {src/services => crates/codex-services/src}/user_plugin/mod.rs (100%) rename {src/services => crates/codex-services/src}/user_plugin/oauth.rs (99%) rename {src/services => crates/codex-services/src}/user_plugin/token_refresh.rs (99%) diff --git a/Cargo.lock b/Cargo.lock index c5abb15d..cfb36ffa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -826,6 +826,7 @@ dependencies = [ "codex-events", "codex-models", "codex-parsers", + "codex-services", "codex-utils", "cron", "csv", @@ -979,6 +980,56 @@ dependencies = [ "zip", ] +[[package]] +name = "codex-services" +version = "0.0.0" +dependencies = [ + "anyhow", + "base64 0.22.1", + "chrono", + "codex-config", + "codex-db", + "codex-events", + "codex-models", + "codex-parsers", + "codex-utils", + "csv", + "dashmap", + "futures", + "handlebars", + "image", + "jxl-oxide", + "lettre", + "lru", + "openidconnect", + "opentelemetry 0.32.0", + "opentelemetry-semantic-conventions 0.32.0", + "opentelemetry_sdk", + "parking_lot", + "pdfium-render", + "rand 0.10.0", + "regex", + "reqwest 0.13.2", + "resvg", + "sea-orm", + "serde", + "serde_json", + "serial_test", + "sha2", + "sysinfo", + "tempfile", + "thiserror 2.0.18", + "tokio", + "tokio-stream", + "tokio-util", + "tracing", + "tracing-subscriber", + "tracing-test", + "urlencoding", + "utoipa", + "uuid", +] + [[package]] name = "codex-utils" version = "0.0.0" diff --git a/Cargo.toml b/Cargo.toml index b192489a..b78ce9a4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,7 +14,7 @@ path = "src/main.rs" [features] default = ["rar", "observability"] -rar = ["codex-parsers/rar"] +rar = ["codex-parsers/rar", "codex-services/rar"] embed-frontend = [] observability = [ "dep:opentelemetry", @@ -25,6 +25,7 @@ observability = [ "dep:axum-tracing-opentelemetry", "dep:tonic", "dep:sysinfo", + "codex-services/observability", ] [workspace] @@ -37,6 +38,7 @@ members = [ "crates/codex-utils", "crates/codex-parsers", "crates/codex-db", + "crates/codex-services", ] # Shared dependencies inherited by workspace members. Only deps that are @@ -64,6 +66,7 @@ codex-db = { path = "crates/codex-db" } codex-events = { path = "crates/codex-events" } codex-models = { path = "crates/codex-models" } codex-parsers = { path = "crates/codex-parsers", default-features = false } +codex-services = { path = "crates/codex-services" } codex-utils = { path = "crates/codex-utils" } # Shared dev-dependencies @@ -146,6 +149,7 @@ codex-db = { workspace = true } codex-events = { workspace = true } codex-models = { workspace = true } codex-parsers = { workspace = true } +codex-services = { workspace = true } codex-utils = { workspace = true } tokio = { workspace = true } uuid = { workspace = true } diff --git a/crates/codex-services/Cargo.toml b/crates/codex-services/Cargo.toml new file mode 100644 index 00000000..38b15061 --- /dev/null +++ b/crates/codex-services/Cargo.toml @@ -0,0 +1,117 @@ +[package] +name = "codex-services" +version = "0.0.0" +edition = "2024" +publish = false + +[lib] +name = "codex_services" +path = "src/lib.rs" + +[features] +default = [] +# Enables OTel meter instruments. When off, `metrics::*` is a no-op stub. +observability = [ + "dep:opentelemetry", + "dep:opentelemetry-semantic-conventions", + "dep:sysinfo", +] +# Forwards to codex-parsers/rar for thumbnail generation from CBR. +rar = ["codex-parsers/rar"] + +[dependencies] +# Workspace-inherited +anyhow = { workspace = true } +chrono = { workspace = true } +serde = { workspace = true } +tokio = { workspace = true } +tracing = { workspace = true } +utoipa = { workspace = true } +uuid = { workspace = true } + +# Workspace-internal +codex-config = { workspace = true } +codex-db = { workspace = true } +codex-events = { workspace = true } +codex-models = { workspace = true } +codex-parsers = { workspace = true } +codex-utils = { workspace = true } + +# Crate-specific +sea-orm = { version = "1.1", features = [ + "sqlx-postgres", + "sqlx-sqlite", + "runtime-tokio-rustls", + "macros", + "with-chrono", + "with-uuid", +] } +serde_json = "1.0" +thiserror = "2.0" +csv = "1.3" + +# Auth / identity +openidconnect = "4" +sha2 = "0.10" +base64 = "0.22" +rand = "0.10" + +# Email +lettre = { version = "0.11", default-features = false, features = [ + "tokio1", + "tokio1-rustls-tls", + "smtp-transport", + "builder", +] } + +# HTTP client for plugin cover downloads + OAuth token exchange +reqwest = { version = "0.13", default-features = false, features = [ + "rustls", + "json", + "form", +] } + +# PDF handle cache (uses pdfium-render types) +pdfium-render = { version = "0.8", features = ["sync"] } + +# Image processing (cover thumbnails) +image = { version = "0.25", features = ["avif"] } +resvg = "0.47" +jxl-oxide = "0.12" + +# Templating (plugin search query templates) +handlebars = "6" + +# Regex (release matching, ISBN) +regex = "1.10" + +# Concurrent data structures +dashmap = "6.1" +lru = "0.18" +parking_lot = "0.12" + +# Async streams +futures = "0.3" +tokio-util = { version = "0.7", features = ["io"] } +tokio-stream = "0.1" + +# URL encoding (plugin templates) +urlencoding = "2.1" + +# Observability (optional) +opentelemetry = { version = "0.32", optional = true } +opentelemetry-semantic-conventions = { version = "0.32", optional = true } +sysinfo = { version = "0.39", default-features = false, features = ["system"], optional = true } + +[dev-dependencies] +tempfile = { workspace = true } +serial_test = { workspace = true } +codex-db = { workspace = true, features = ["test-utils"] } +# Capturing tracing layer for plugin::manager span-emission tests and the +# metrics::tests in-memory exporter checks. +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +tracing-test = "0.2" +# In-memory metric exporter used by metrics::tests. The matching prod +# feature lives in observability above; pull the SDK in dev with the +# testing feature so the dev build always has it available. +opentelemetry_sdk = { version = "0.32", features = ["rt-tokio", "trace", "metrics", "testing"] } diff --git a/src/services/auth_tracking.rs b/crates/codex-services/src/auth_tracking.rs similarity index 100% rename from src/services/auth_tracking.rs rename to crates/codex-services/src/auth_tracking.rs diff --git a/src/services/book_export_collector.rs b/crates/codex-services/src/book_export_collector.rs similarity index 99% rename from src/services/book_export_collector.rs rename to crates/codex-services/src/book_export_collector.rs index be93e947..b9dfcb5f 100644 --- a/src/services/book_export_collector.rs +++ b/crates/codex-services/src/book_export_collector.rs @@ -11,7 +11,7 @@ use std::collections::HashMap; use std::fmt; use uuid::Uuid; -use crate::services::content_filter::ContentFilter; +use crate::content_filter::ContentFilter; use codex_db::entities::{book_metadata, books, read_progress}; use codex_db::repositories::{ GenreRepository, LibraryRepository, ReadProgressRepository, SeriesRepository, TagRepository, diff --git a/src/services/cleanup_subscriber.rs b/crates/codex-services/src/cleanup_subscriber.rs similarity index 100% rename from src/services/cleanup_subscriber.rs rename to crates/codex-services/src/cleanup_subscriber.rs diff --git a/src/services/content_filter.rs b/crates/codex-services/src/content_filter.rs similarity index 100% rename from src/services/content_filter.rs rename to crates/codex-services/src/content_filter.rs diff --git a/src/services/email.rs b/crates/codex-services/src/email.rs similarity index 100% rename from src/services/email.rs rename to crates/codex-services/src/email.rs diff --git a/src/services/export_storage.rs b/crates/codex-services/src/export_storage.rs similarity index 100% rename from src/services/export_storage.rs rename to crates/codex-services/src/export_storage.rs diff --git a/src/services/file_cleanup.rs b/crates/codex-services/src/file_cleanup.rs similarity index 100% rename from src/services/file_cleanup.rs rename to crates/codex-services/src/file_cleanup.rs diff --git a/src/services/filter.rs b/crates/codex-services/src/filter.rs similarity index 100% rename from src/services/filter.rs rename to crates/codex-services/src/filter.rs diff --git a/src/services/inflight_thumbnails.rs b/crates/codex-services/src/inflight_thumbnails.rs similarity index 100% rename from src/services/inflight_thumbnails.rs rename to crates/codex-services/src/inflight_thumbnails.rs diff --git a/src/services/mod.rs b/crates/codex-services/src/lib.rs similarity index 87% rename from src/services/mod.rs rename to crates/codex-services/src/lib.rs index 7d6d1136..7c34bdf1 100644 --- a/src/services/mod.rs +++ b/crates/codex-services/src/lib.rs @@ -1,5 +1,14 @@ pub mod auth_tracking; pub mod book_export_collector; + +// OTel meter / instrument plumbing for plugin and task lifecycle events. +// Behind the `observability` feature; the stub keeps callsites cfg-free. +#[cfg(feature = "observability")] +pub mod metrics; +#[cfg(not(feature = "observability"))] +#[path = "metrics_stub.rs"] +pub mod metrics; + pub mod cleanup_subscriber; pub mod content_filter; pub mod email; diff --git a/src/services/library_jobs/mod.rs b/crates/codex-services/src/library_jobs/mod.rs similarity index 100% rename from src/services/library_jobs/mod.rs rename to crates/codex-services/src/library_jobs/mod.rs diff --git a/src/services/library_jobs/types.rs b/crates/codex-services/src/library_jobs/types.rs similarity index 100% rename from src/services/library_jobs/types.rs rename to crates/codex-services/src/library_jobs/types.rs diff --git a/src/services/library_jobs/validation.rs b/crates/codex-services/src/library_jobs/validation.rs similarity index 99% rename from src/services/library_jobs/validation.rs rename to crates/codex-services/src/library_jobs/validation.rs index b6408a29..61c2b361 100644 --- a/src/services/library_jobs/validation.rs +++ b/crates/codex-services/src/library_jobs/validation.rs @@ -12,7 +12,7 @@ use thiserror::Error; use std::str::FromStr; -use crate::services::metadata::FieldGroup; +use crate::metadata::FieldGroup; use codex_db::repositories::PluginsRepository; use codex_utils::cron::{validate_cron_expression, validate_timezone}; diff --git a/src/services/metadata/apply.rs b/crates/codex-services/src/metadata/apply.rs similarity index 99% rename from src/services/metadata/apply.rs rename to crates/codex-services/src/metadata/apply.rs index d43b4418..05dc4b3e 100644 --- a/src/services/metadata/apply.rs +++ b/crates/codex-services/src/metadata/apply.rs @@ -12,8 +12,8 @@ use std::sync::Arc; use tracing::warn; use uuid::Uuid; -use crate::services::ThumbnailService; -use crate::services::plugin::PluginSeriesMetadata; +use crate::ThumbnailService; +use crate::plugin::PluginSeriesMetadata; use codex_db::entities::SeriesStatus; use codex_db::entities::plugins::{Model as Plugin, PluginPermission}; use codex_db::entities::series_metadata::Model as SeriesMetadata; diff --git a/src/services/metadata/book_apply.rs b/crates/codex-services/src/metadata/book_apply.rs similarity index 99% rename from src/services/metadata/book_apply.rs rename to crates/codex-services/src/metadata/book_apply.rs index 85f5568a..fc874a87 100644 --- a/src/services/metadata/book_apply.rs +++ b/crates/codex-services/src/metadata/book_apply.rs @@ -11,8 +11,8 @@ use std::sync::Arc; use tracing::warn; use uuid::Uuid; -use crate::services::ThumbnailService; -use crate::services::plugin::protocol::PluginBookMetadata; +use crate::ThumbnailService; +use crate::plugin::protocol::PluginBookMetadata; use codex_db::entities::book_metadata::Model as BookMetadata; use codex_db::entities::plugins::{Model as Plugin, PluginPermission}; use codex_db::repositories::{BookExternalIdRepository, BookMetadataRepository}; diff --git a/src/services/metadata/cover.rs b/crates/codex-services/src/metadata/cover.rs similarity index 99% rename from src/services/metadata/cover.rs rename to crates/codex-services/src/metadata/cover.rs index dcf661d9..2f71ec82 100644 --- a/src/services/metadata/cover.rs +++ b/crates/codex-services/src/metadata/cover.rs @@ -9,7 +9,7 @@ use std::sync::Arc; use tracing::warn; use uuid::Uuid; -use crate::services::ThumbnailService; +use crate::ThumbnailService; use codex_db::repositories::{ BookCoversRepository, SeriesCoversRepository, SeriesRepository, TaskRepository, }; diff --git a/src/services/metadata/field_groups.rs b/crates/codex-services/src/metadata/field_groups.rs similarity index 98% rename from src/services/metadata/field_groups.rs rename to crates/codex-services/src/metadata/field_groups.rs index e072b79b..b05b4a3a 100644 --- a/src/services/metadata/field_groups.rs +++ b/crates/codex-services/src/metadata/field_groups.rs @@ -2,7 +2,7 @@ //! //! The scheduled refresh exposes a small set of named groups (Ratings, //! Status, Counts, etc.) instead of asking the user to pick from the ~20 -//! camelCase field names that [`crate::services::metadata::MetadataApplier`] +//! camelCase field names that [`crate::metadata::MetadataApplier`] //! understands. This module is the authoritative mapping between the two //! vocabularies. //! @@ -130,7 +130,7 @@ impl FromStr for FieldGroup { /// branch silently does nothing — there's a unit test that asserts every /// returned field is one the applier actually knows about. /// -/// [`MetadataApplier`]: crate::services::metadata::MetadataApplier +/// [`MetadataApplier`]: crate::metadata::MetadataApplier pub fn fields_for_group(group: FieldGroup) -> &'static [&'static str] { match group { FieldGroup::Identifiers => &["title", "titleSort", "alternateTitles"], @@ -156,7 +156,7 @@ pub fn fields_for_group(group: FieldGroup) -> &'static [&'static str] { /// /// Returns `None` when both `groups` and `extras` are empty, matching the /// "no filter, apply everything" semantics of -/// [`crate::services::metadata::ApplyOptions::fields_filter`]. +/// [`crate::metadata::ApplyOptions::fields_filter`]. pub fn fields_for_groups<S: AsRef<str>>(groups: &[S], extras: &[S]) -> Option<HashSet<String>> { if groups.is_empty() && extras.is_empty() { return None; diff --git a/src/services/metadata/mod.rs b/crates/codex-services/src/metadata/mod.rs similarity index 100% rename from src/services/metadata/mod.rs rename to crates/codex-services/src/metadata/mod.rs diff --git a/src/services/metadata/preprocessing/conditions.rs b/crates/codex-services/src/metadata/preprocessing/conditions.rs similarity index 100% rename from src/services/metadata/preprocessing/conditions.rs rename to crates/codex-services/src/metadata/preprocessing/conditions.rs diff --git a/src/services/metadata/preprocessing/context.rs b/crates/codex-services/src/metadata/preprocessing/context.rs similarity index 100% rename from src/services/metadata/preprocessing/context.rs rename to crates/codex-services/src/metadata/preprocessing/context.rs diff --git a/src/services/metadata/preprocessing/mod.rs b/crates/codex-services/src/metadata/preprocessing/mod.rs similarity index 100% rename from src/services/metadata/preprocessing/mod.rs rename to crates/codex-services/src/metadata/preprocessing/mod.rs diff --git a/src/services/metadata/preprocessing/rules.rs b/crates/codex-services/src/metadata/preprocessing/rules.rs similarity index 100% rename from src/services/metadata/preprocessing/rules.rs rename to crates/codex-services/src/metadata/preprocessing/rules.rs diff --git a/src/services/metadata/preprocessing/templates.rs b/crates/codex-services/src/metadata/preprocessing/templates.rs similarity index 100% rename from src/services/metadata/preprocessing/templates.rs rename to crates/codex-services/src/metadata/preprocessing/templates.rs diff --git a/src/services/metadata/preprocessing/types.rs b/crates/codex-services/src/metadata/preprocessing/types.rs similarity index 100% rename from src/services/metadata/preprocessing/types.rs rename to crates/codex-services/src/metadata/preprocessing/types.rs diff --git a/src/services/metadata/refresh_planner.rs b/crates/codex-services/src/metadata/refresh_planner.rs similarity index 98% rename from src/services/metadata/refresh_planner.rs rename to crates/codex-services/src/metadata/refresh_planner.rs index 9fe93418..29433c4b 100644 --- a/src/services/metadata/refresh_planner.rs +++ b/crates/codex-services/src/metadata/refresh_planner.rs @@ -19,7 +19,7 @@ use codex_db::entities::plugins::Model as Plugin; use codex_db::entities::series_external_ids::{self, Model as SeriesExternalId}; use codex_db::repositories::{PluginsRepository, SeriesExternalIdRepository, SeriesRepository}; -use crate::services::library_jobs::MetadataRefreshJobConfig; +use crate::library_jobs::MetadataRefreshJobConfig; /// Reason a series was skipped during planning. #[derive(Debug, Clone, PartialEq, Eq)] @@ -240,8 +240,8 @@ pub fn fields_filter_from_job_config(config: &MetadataRefreshJobConfig) -> Optio #[cfg(test)] mod tests { use super::*; - use crate::services::library_jobs::{MetadataRefreshJobConfig, RefreshScope}; - use crate::services::plugin::protocol::PluginScope; + use crate::library_jobs::{MetadataRefreshJobConfig, RefreshScope}; + use crate::plugin::protocol::PluginScope; use codex_db::ScanningStrategy; use codex_db::entities::plugins::PluginPermission; use codex_db::repositories::{LibraryRepository, PluginsRepository, SeriesRepository}; diff --git a/src/observability/metrics.rs b/crates/codex-services/src/metrics.rs similarity index 99% rename from src/observability/metrics.rs rename to crates/codex-services/src/metrics.rs index 280c4d41..d08bf365 100644 --- a/src/observability/metrics.rs +++ b/crates/codex-services/src/metrics.rs @@ -8,8 +8,8 @@ //! operator docs. //! //! All entry points are safe to call when observability is disabled: the -//! global meter provider is a no-op until [`crate::observability::init`] -//! installs one. +//! global meter provider is a no-op until `codex::observability::init` +//! (still in the root binary crate) installs one. use std::sync::OnceLock; use std::sync::atomic::{AtomicI64, Ordering}; diff --git a/src/observability/metrics_stub.rs b/crates/codex-services/src/metrics_stub.rs similarity index 100% rename from src/observability/metrics_stub.rs rename to crates/codex-services/src/metrics_stub.rs diff --git a/src/services/oidc.rs b/crates/codex-services/src/oidc.rs similarity index 100% rename from src/services/oidc.rs rename to crates/codex-services/src/oidc.rs diff --git a/src/services/pdf_cache.rs b/crates/codex-services/src/pdf_cache.rs similarity index 100% rename from src/services/pdf_cache.rs rename to crates/codex-services/src/pdf_cache.rs diff --git a/src/services/pdf_handle_cache.rs b/crates/codex-services/src/pdf_handle_cache.rs similarity index 100% rename from src/services/pdf_handle_cache.rs rename to crates/codex-services/src/pdf_handle_cache.rs diff --git a/src/services/pdf_handle_cache_subscriber.rs b/crates/codex-services/src/pdf_handle_cache_subscriber.rs similarity index 99% rename from src/services/pdf_handle_cache_subscriber.rs rename to crates/codex-services/src/pdf_handle_cache_subscriber.rs index e86e1255..a1625853 100644 --- a/src/services/pdf_handle_cache_subscriber.rs +++ b/crates/codex-services/src/pdf_handle_cache_subscriber.rs @@ -16,7 +16,7 @@ use std::sync::Arc; use tokio::sync::broadcast; use tracing::{debug, error, info, warn}; -use crate::services::PdfHandleCache; +use crate::PdfHandleCache; use codex_events::{EntityChangeEvent, EntityEvent, EventBroadcaster}; /// Background service that listens for book mutation events and drops the diff --git a/src/services/plugin/handle.rs b/crates/codex-services/src/plugin/handle.rs similarity index 99% rename from src/services/plugin/handle.rs rename to crates/codex-services/src/plugin/handle.rs index afbc9496..d7c7a003 100644 --- a/src/services/plugin/handle.rs +++ b/crates/codex-services/src/plugin/handle.rs @@ -150,7 +150,7 @@ pub struct PluginHandle { release_db: Option<DatabaseConnection>, /// Optional scheduler handle so the releases handler can reconcile /// release-source schedules immediately after `releases/register_sources`. - scheduler: Option<crate::services::scheduler_handle::SharedSchedulerReconciler>, + scheduler: Option<crate::scheduler_handle::SharedSchedulerReconciler>, } impl PluginHandle { @@ -198,7 +198,7 @@ impl PluginHandle { /// `releases/register_sources`. Builder-style. pub fn with_scheduler( mut self, - scheduler: crate::services::scheduler_handle::SharedSchedulerReconciler, + scheduler: crate::scheduler_handle::SharedSchedulerReconciler, ) -> Self { self.scheduler = Some(scheduler); self diff --git a/src/services/plugin/health.rs b/crates/codex-services/src/plugin/health.rs similarity index 100% rename from src/services/plugin/health.rs rename to crates/codex-services/src/plugin/health.rs diff --git a/src/services/plugin/library.rs b/crates/codex-services/src/plugin/library.rs similarity index 98% rename from src/services/plugin/library.rs rename to crates/codex-services/src/plugin/library.rs index b7a51aab..6dffd457 100644 --- a/src/services/plugin/library.rs +++ b/crates/codex-services/src/plugin/library.rs @@ -9,9 +9,7 @@ use std::collections::HashMap; use tracing::{debug, warn}; use uuid::Uuid; -use crate::services::plugin::protocol::{ - UserLibraryEntry, UserLibraryExternalId, UserReadingStatus, -}; +use crate::plugin::protocol::{UserLibraryEntry, UserLibraryExternalId, UserReadingStatus}; use codex_db::entities::SeriesStatus; use codex_db::repositories::{ AlternateTitleRepository, BookRepository, GenreRepository, ReadProgressRepository, diff --git a/src/services/plugin/manager.rs b/crates/codex-services/src/plugin/manager.rs similarity index 99% rename from src/services/plugin/manager.rs rename to crates/codex-services/src/plugin/manager.rs index 65bc308c..9171392f 100644 --- a/src/services/plugin/manager.rs +++ b/crates/codex-services/src/plugin/manager.rs @@ -46,14 +46,14 @@ use tokio::sync::{Mutex, RwLock}; use tracing::{Span, debug, error, field::Empty, info, warn}; use uuid::Uuid; -use crate::services::PluginMetricsService; +use crate::PluginMetricsService; use codex_db::entities::plugins; use codex_db::entities::user_plugins; use codex_db::repositories::{ FailureContext, PluginFailuresRepository, PluginsRepository, UserPluginsRepository, }; -use crate::services::user_plugin::token_refresh::{self, RefreshResult}; +use crate::user_plugin::token_refresh::{self, RefreshResult}; use super::handle::{PluginConfig, PluginError, PluginHandle, PluginState}; use super::process::PluginProcessConfig; @@ -331,11 +331,11 @@ pub struct PluginManager { /// Optional metrics service for recording plugin operation metrics metrics_service: Option<Arc<PluginMetricsService>>, /// Optional plugin file storage for resolving plugin data directories - plugin_file_storage: Option<Arc<crate::services::PluginFileStorage>>, + plugin_file_storage: Option<Arc<crate::PluginFileStorage>>, /// Optional scheduler handle so the releases reverse-RPC handler can /// trigger a release-source reconcile when a plugin calls /// `releases/register_sources`. - scheduler: Option<crate::services::scheduler_handle::SharedSchedulerReconciler>, + scheduler: Option<crate::scheduler_handle::SharedSchedulerReconciler>, } impl PluginManager { @@ -366,10 +366,7 @@ impl PluginManager { } /// Set the plugin file storage for resolving plugin data directories - pub fn with_plugin_file_storage( - mut self, - storage: Arc<crate::services::PluginFileStorage>, - ) -> Self { + pub fn with_plugin_file_storage(mut self, storage: Arc<crate::PluginFileStorage>) -> Self { self.plugin_file_storage = Some(storage); self } @@ -379,7 +376,7 @@ impl PluginManager { /// `releases/register_sources`. Builder-style. pub fn with_scheduler( mut self, - scheduler: crate::services::scheduler_handle::SharedSchedulerReconciler, + scheduler: crate::scheduler_handle::SharedSchedulerReconciler, ) -> Self { self.scheduler = Some(scheduler); self diff --git a/src/services/plugin/mod.rs b/crates/codex-services/src/plugin/mod.rs similarity index 100% rename from src/services/plugin/mod.rs rename to crates/codex-services/src/plugin/mod.rs diff --git a/src/services/plugin/permissions.rs b/crates/codex-services/src/plugin/permissions.rs similarity index 100% rename from src/services/plugin/permissions.rs rename to crates/codex-services/src/plugin/permissions.rs diff --git a/src/services/plugin/process.rs b/crates/codex-services/src/plugin/process.rs similarity index 100% rename from src/services/plugin/process.rs rename to crates/codex-services/src/plugin/process.rs diff --git a/src/services/plugin/protocol.rs b/crates/codex-services/src/plugin/protocol.rs similarity index 99% rename from src/services/plugin/protocol.rs rename to crates/codex-services/src/plugin/protocol.rs index 7e921e42..39d03135 100644 --- a/src/services/plugin/protocol.rs +++ b/crates/codex-services/src/plugin/protocol.rs @@ -1071,7 +1071,7 @@ pub struct ReleasePollResponse { /// (in addition to anything the plugin already streamed via /// `releases/record`). #[serde(default)] - pub candidates: Vec<crate::services::release::candidate::ReleaseCandidate>, + pub candidates: Vec<crate::release::candidate::ReleaseCandidate>, /// New etag observed by the plugin (e.g. from the upstream feed's /// `ETag` header). The host stores this on the source row for the /// next poll's conditional-GET. diff --git a/src/services/plugin/recommendations.rs b/crates/codex-services/src/plugin/recommendations.rs similarity index 100% rename from src/services/plugin/recommendations.rs rename to crates/codex-services/src/plugin/recommendations.rs diff --git a/src/services/plugin/releases_handler.rs b/crates/codex-services/src/plugin/releases_handler.rs similarity index 99% rename from src/services/plugin/releases_handler.rs rename to crates/codex-services/src/plugin/releases_handler.rs index 149f97f3..4664c839 100644 --- a/src/services/plugin/releases_handler.rs +++ b/crates/codex-services/src/plugin/releases_handler.rs @@ -23,11 +23,11 @@ use super::protocol::{ JsonRpcError, JsonRpcRequest, JsonRpcResponse, ReleaseSourceCapability, RequestId, error_codes, methods, }; -use crate::services::release::auto_ignore::{is_outside_tracking_scope, should_auto_ignore}; -use crate::services::release::candidate::ReleaseCandidate; -use crate::services::release::languages::{includes, resolve_for_series}; -use crate::services::release::matcher::{evaluate, resolve_threshold}; -use crate::services::scheduler_handle::SharedSchedulerReconciler; +use crate::release::auto_ignore::{is_outside_tracking_scope, should_auto_ignore}; +use crate::release::candidate::ReleaseCandidate; +use crate::release::languages::{includes, resolve_for_series}; +use crate::release::matcher::{evaluate, resolve_threshold}; +use crate::scheduler_handle::SharedSchedulerReconciler; use codex_db::entities::release_ledger::state as ledger_state; use codex_db::entities::release_sources::kind as source_kind; use codex_db::repositories::{ @@ -473,10 +473,9 @@ impl ReleasesRequestHandler { let candidate_volumes = accepted.candidate.volumes.clone(); let candidate_chapters = accepted.candidate.chapters.clone(); let candidate_volume_primary = - crate::services::release::candidate::primary_value(candidate_volumes.as_ref()) - .map(|v| v as i32); + crate::release::candidate::primary_value(candidate_volumes.as_ref()).map(|v| v as i32); let candidate_chapter_primary = - crate::services::release::candidate::primary_value(candidate_chapters.as_ref()); + crate::release::candidate::primary_value(candidate_chapters.as_ref()); let candidate_language = accepted.candidate.language.clone(); // Auto-ignore decision. Two independent reasons can mark an @@ -591,11 +590,8 @@ impl ReleasesRequestHandler { ); } else if let Some(broadcaster) = codex_events::current_recording_broadcaster() { let series_title = - crate::tasks::handlers::poll_release_source::lookup_series_title( - &self.db, - outcome.row.series_id, - ) - .await; + crate::release::announce::lookup_series_title(&self.db, outcome.row.series_id) + .await; let _ = broadcaster.emit(codex_events::EntityChangeEvent::release_announced( outcome.row.id, outcome.row.series_id, @@ -1210,8 +1206,8 @@ pub fn is_releases_method(method: &str) -> bool { #[cfg(test)] mod tests { use super::*; - use crate::services::plugin::protocol::ReleaseSourceKind; - use crate::services::release::candidate::{NumericSpan, SeriesMatch}; + use crate::plugin::protocol::ReleaseSourceKind; + use crate::release::candidate::{NumericSpan, SeriesMatch}; use codex_db::ScanningStrategy; use codex_db::entities::release_sources::{self, kind}; use codex_db::repositories::{ diff --git a/src/services/plugin/rpc.rs b/crates/codex-services/src/plugin/rpc.rs similarity index 100% rename from src/services/plugin/rpc.rs rename to crates/codex-services/src/plugin/rpc.rs diff --git a/src/services/plugin/secrets.rs b/crates/codex-services/src/plugin/secrets.rs similarity index 98% rename from src/services/plugin/secrets.rs rename to crates/codex-services/src/plugin/secrets.rs index 5e98b301..68f88b80 100644 --- a/src/services/plugin/secrets.rs +++ b/crates/codex-services/src/plugin/secrets.rs @@ -8,7 +8,7 @@ //! //! ```rust //! use serde_json::json; -//! use codex::services::plugin::secrets::SecretValue; +//! use codex_services::plugin::secrets::SecretValue; //! //! let secret = SecretValue::new(json!({"api_key": "sk-12345"})); //! diff --git a/src/services/plugin/storage.rs b/crates/codex-services/src/plugin/storage.rs similarity index 100% rename from src/services/plugin/storage.rs rename to crates/codex-services/src/plugin/storage.rs diff --git a/src/services/plugin/storage_handler.rs b/crates/codex-services/src/plugin/storage_handler.rs similarity index 99% rename from src/services/plugin/storage_handler.rs rename to crates/codex-services/src/plugin/storage_handler.rs index ca4997f3..fc710a53 100644 --- a/src/services/plugin/storage_handler.rs +++ b/crates/codex-services/src/plugin/storage_handler.rs @@ -336,7 +336,7 @@ impl WithId for JsonRpcResponse { #[cfg(test)] mod tests { use super::*; - use crate::services::plugin::protocol::RequestId; + use crate::plugin::protocol::RequestId; use codex_db::entities::plugins; use codex_db::entities::users; use codex_db::repositories::{PluginsRepository, UserPluginsRepository, UserRepository}; diff --git a/src/services/plugin/sync.rs b/crates/codex-services/src/plugin/sync.rs similarity index 100% rename from src/services/plugin/sync.rs rename to crates/codex-services/src/plugin/sync.rs diff --git a/src/services/plugin_file_storage.rs b/crates/codex-services/src/plugin_file_storage.rs similarity index 100% rename from src/services/plugin_file_storage.rs rename to crates/codex-services/src/plugin_file_storage.rs diff --git a/src/services/plugin_metrics.rs b/crates/codex-services/src/plugin_metrics.rs similarity index 98% rename from src/services/plugin_metrics.rs rename to crates/codex-services/src/plugin_metrics.rs index 88c8c734..fdc44e1c 100644 --- a/src/services/plugin_metrics.rs +++ b/crates/codex-services/src/plugin_metrics.rs @@ -197,7 +197,7 @@ impl PluginMetricsService { ) { // OTel dual-write: emit the counter + histogram before taking the // write lock so the OTel cost doesn't widen the critical section. - crate::observability::metrics::record_plugin_request( + crate::metrics::record_plugin_request( &plugin_id.to_string(), method, "success", @@ -253,7 +253,7 @@ impl PluginMetricsService { duration_ms: u64, error_code: Option<&str>, ) { - crate::observability::metrics::record_plugin_request( + crate::metrics::record_plugin_request( &plugin_id.to_string(), method, "failure", @@ -314,7 +314,7 @@ impl PluginMetricsService { /// Record a rate limit rejection pub async fn record_rate_limit(&self, plugin_id: Uuid, plugin_name: &str) { - crate::observability::metrics::record_plugin_rate_limit_rejection(&plugin_id.to_string()); + crate::metrics::record_plugin_rate_limit_rejection(&plugin_id.to_string()); let mut plugins = self.plugins.write().await; let entry = plugins diff --git a/src/services/rate_limiter.rs b/crates/codex-services/src/rate_limiter.rs similarity index 100% rename from src/services/rate_limiter.rs rename to crates/codex-services/src/rate_limiter.rs diff --git a/src/services/read_progress.rs b/crates/codex-services/src/read_progress.rs similarity index 100% rename from src/services/read_progress.rs rename to crates/codex-services/src/read_progress.rs diff --git a/src/services/refresh_token.rs b/crates/codex-services/src/refresh_token.rs similarity index 100% rename from src/services/refresh_token.rs rename to crates/codex-services/src/refresh_token.rs diff --git a/crates/codex-services/src/release/announce.rs b/crates/codex-services/src/release/announce.rs new file mode 100644 index 00000000..5f0723aa --- /dev/null +++ b/crates/codex-services/src/release/announce.rs @@ -0,0 +1,29 @@ +//! Helpers for emitting `ReleaseAnnounced` notifications. +//! +//! Lives in `services::release` because both the services-side reverse-RPC +//! handler (plugin → host announce) and the tasks-side polling worker need +//! the same series-title lookup before broadcasting. Keeping the helper here +//! means tasks depends on services, not the other way around. + +use codex_db::repositories::SeriesRepository; +use sea_orm::DatabaseConnection; +use tracing::warn; +use uuid::Uuid; + +/// Resolve the display title for a series, preferring `series_metadata.title` +/// and falling back to the directory-derived `series.name`. Returns an empty +/// string if the series row is missing (shouldn't happen for a valid ledger +/// insert, but we don't want a notification failure to surface as a panic). +pub async fn lookup_series_title(db: &DatabaseConnection, series_id: Uuid) -> String { + match SeriesRepository::get_with_metadata(db, series_id).await { + Ok(Some((series, metadata))) => metadata.map(|m| m.title).unwrap_or(series.name), + Ok(None) => String::new(), + Err(e) => { + warn!( + "Failed to look up title for series {} (release notification): {}", + series_id, e + ); + String::new() + } + } +} diff --git a/src/services/release/auto_ignore.rs b/crates/codex-services/src/release/auto_ignore.rs similarity index 100% rename from src/services/release/auto_ignore.rs rename to crates/codex-services/src/release/auto_ignore.rs diff --git a/src/services/release/backoff.rs b/crates/codex-services/src/release/backoff.rs similarity index 100% rename from src/services/release/backoff.rs rename to crates/codex-services/src/release/backoff.rs diff --git a/src/services/release/candidate.rs b/crates/codex-services/src/release/candidate.rs similarity index 100% rename from src/services/release/candidate.rs rename to crates/codex-services/src/release/candidate.rs diff --git a/src/services/release/languages.rs b/crates/codex-services/src/release/languages.rs similarity index 100% rename from src/services/release/languages.rs rename to crates/codex-services/src/release/languages.rs diff --git a/src/services/release/matcher.rs b/crates/codex-services/src/release/matcher.rs similarity index 97% rename from src/services/release/matcher.rs rename to crates/codex-services/src/release/matcher.rs index 3cdaba82..46434017 100644 --- a/src/services/release/matcher.rs +++ b/crates/codex-services/src/release/matcher.rs @@ -132,7 +132,7 @@ pub fn resolve_threshold(per_series_override: Option<f64>) -> f64 { #[cfg(test)] mod tests { use super::*; - use crate::services::release::candidate::{NumericSpan, SeriesMatch}; + use crate::release::candidate::{NumericSpan, SeriesMatch}; use chrono::Duration; fn make_candidate(confidence: f64) -> ReleaseCandidate { @@ -257,7 +257,7 @@ mod tests { #[test] fn into_ledger_entry_carries_media_url_pair() { - use crate::services::release::candidate::MediaUrlKind; + use crate::release::candidate::MediaUrlKind; let mut cand = make_candidate(0.9); cand.media_url = Some("https://nyaa.si/download/1.torrent".to_string()); cand.media_url_kind = Some(MediaUrlKind::Torrent); @@ -282,7 +282,7 @@ mod tests { #[test] fn rejects_kind_without_media_url() { - use crate::services::release::candidate::MediaUrlKind; + use crate::release::candidate::MediaUrlKind; let mut cand = make_candidate(0.95); cand.media_url = None; cand.media_url_kind = Some(MediaUrlKind::Torrent); @@ -292,7 +292,7 @@ mod tests { #[test] fn rejects_empty_media_url() { - use crate::services::release::candidate::MediaUrlKind; + use crate::release::candidate::MediaUrlKind; let mut cand = make_candidate(0.95); cand.media_url = Some(" ".to_string()); cand.media_url_kind = Some(MediaUrlKind::Torrent); diff --git a/src/services/release/mod.rs b/crates/codex-services/src/release/mod.rs similarity index 98% rename from src/services/release/mod.rs rename to crates/codex-services/src/release/mod.rs index 76960bb6..0b8720f3 100644 --- a/src/services/release/mod.rs +++ b/crates/codex-services/src/release/mod.rs @@ -22,6 +22,7 @@ //! the threshold and hands the survivors to the ledger repository, which is //! itself idempotent on the natural dedup keys. +pub mod announce; pub mod auto_ignore; pub mod backoff; pub mod candidate; diff --git a/src/services/release/schedule.rs b/crates/codex-services/src/release/schedule.rs similarity index 98% rename from src/services/release/schedule.rs rename to crates/codex-services/src/release/schedule.rs index 6d42555a..2e67f6cf 100644 --- a/src/services/release/schedule.rs +++ b/crates/codex-services/src/release/schedule.rs @@ -12,7 +12,7 @@ //! short-circuited rather than rewriting the cron expression. This keeps //! the cron source-of-truth simple: one row, one schedule. -use crate::services::settings::SettingsService; +use crate::settings::SettingsService; /// Compile-time fallback when neither the per-source override nor the /// server-wide setting are present. Daily at midnight (5-field POSIX cron). diff --git a/src/services/release/seed.rs b/crates/codex-services/src/release/seed.rs similarity index 100% rename from src/services/release/seed.rs rename to crates/codex-services/src/release/seed.rs diff --git a/src/services/release/tracking_toggle.rs b/crates/codex-services/src/release/tracking_toggle.rs similarity index 99% rename from src/services/release/tracking_toggle.rs rename to crates/codex-services/src/release/tracking_toggle.rs index c8dcd53d..f98d7fd2 100644 --- a/src/services/release/tracking_toggle.rs +++ b/crates/codex-services/src/release/tracking_toggle.rs @@ -13,7 +13,7 @@ use serde::{Deserialize, Serialize}; use std::sync::Arc; use uuid::Uuid; -use crate::services::release::seed::seed_tracking_for_series; +use crate::release::seed::seed_tracking_for_series; use codex_db::repositories::{SeriesRepository, SeriesTrackingRepository, TrackingUpdate}; use codex_events::{EntityChangeEvent, EntityEvent, EventBroadcaster}; diff --git a/src/services/release/upstream_gap.rs b/crates/codex-services/src/release/upstream_gap.rs similarity index 100% rename from src/services/release/upstream_gap.rs rename to crates/codex-services/src/release/upstream_gap.rs diff --git a/src/services/scheduler_handle.rs b/crates/codex-services/src/scheduler_handle.rs similarity index 100% rename from src/services/scheduler_handle.rs rename to crates/codex-services/src/scheduler_handle.rs diff --git a/src/services/series_export_collector.rs b/crates/codex-services/src/series_export_collector.rs similarity index 99% rename from src/services/series_export_collector.rs rename to crates/codex-services/src/series_export_collector.rs index c8f63d10..b7a2d7d5 100644 --- a/src/services/series_export_collector.rs +++ b/crates/codex-services/src/series_export_collector.rs @@ -11,7 +11,7 @@ use std::collections::HashMap; use std::fmt; use uuid::Uuid; -use crate::services::content_filter::ContentFilter; +use crate::content_filter::ContentFilter; use codex_db::entities::series; use codex_db::repositories::{ AlternateTitleRepository, BookRepository, ExternalRatingRepository, GenreRepository, diff --git a/src/services/series_export_writer.rs b/crates/codex-services/src/series_export_writer.rs similarity index 100% rename from src/services/series_export_writer.rs rename to crates/codex-services/src/series_export_writer.rs diff --git a/src/services/settings.rs b/crates/codex-services/src/settings.rs similarity index 100% rename from src/services/settings.rs rename to crates/codex-services/src/settings.rs diff --git a/src/services/task_listener.rs b/crates/codex-services/src/task_listener.rs similarity index 100% rename from src/services/task_listener.rs rename to crates/codex-services/src/task_listener.rs diff --git a/src/services/task_metrics.rs b/crates/codex-services/src/task_metrics.rs similarity index 99% rename from src/services/task_metrics.rs rename to crates/codex-services/src/task_metrics.rs index 1d2429af..19fd54c6 100644 --- a/src/services/task_metrics.rs +++ b/crates/codex-services/src/task_metrics.rs @@ -9,7 +9,7 @@ use tokio_util::sync::CancellationToken; use tracing::{debug, error}; use uuid::Uuid; -use crate::services::SettingsService; +use crate::SettingsService; use codex_db::repositories::task_metrics::{TaskCompletionData, TaskMetricsRepository}; /// Number of recent completions to keep for percentile calculation @@ -256,12 +256,7 @@ impl TaskMetricsService { } else { "failure" }; - crate::observability::metrics::record_task_completion( - &task_type, - outcome, - duration_ms, - queue_wait_ms, - ); + crate::metrics::record_task_completion(&task_type, outcome, duration_ms, queue_wait_ms); let completion = TaskCompletion { task_type, diff --git a/src/services/thumbnail.rs b/crates/codex-services/src/thumbnail.rs similarity index 100% rename from src/services/thumbnail.rs rename to crates/codex-services/src/thumbnail.rs diff --git a/src/services/user_plugin/mod.rs b/crates/codex-services/src/user_plugin/mod.rs similarity index 100% rename from src/services/user_plugin/mod.rs rename to crates/codex-services/src/user_plugin/mod.rs diff --git a/src/services/user_plugin/oauth.rs b/crates/codex-services/src/user_plugin/oauth.rs similarity index 99% rename from src/services/user_plugin/oauth.rs rename to crates/codex-services/src/user_plugin/oauth.rs index f4282706..25471a1f 100644 --- a/src/services/user_plugin/oauth.rs +++ b/crates/codex-services/src/user_plugin/oauth.rs @@ -13,7 +13,7 @@ use std::sync::Arc; use tracing::{debug, warn}; use uuid::Uuid; -use crate::services::plugin::protocol::OAuthConfig; +use crate::plugin::protocol::OAuthConfig; /// Duration for pending OAuth state (5 minutes) const OAUTH_STATE_TTL_SECS: i64 = 300; diff --git a/src/services/user_plugin/token_refresh.rs b/crates/codex-services/src/user_plugin/token_refresh.rs similarity index 99% rename from src/services/user_plugin/token_refresh.rs rename to crates/codex-services/src/user_plugin/token_refresh.rs index 7018a310..6ac3c699 100644 --- a/src/services/user_plugin/token_refresh.rs +++ b/crates/codex-services/src/user_plugin/token_refresh.rs @@ -8,7 +8,7 @@ use chrono::{Duration, Utc}; use sea_orm::DatabaseConnection; use tracing::{debug, info, warn}; -use crate::services::plugin::protocol::OAuthConfig; +use crate::plugin::protocol::OAuthConfig; use codex_db::entities::user_plugins; use codex_db::repositories::UserPluginsRepository; diff --git a/src/api/extractors/auth.rs b/src/api/extractors/auth.rs index 4edbe0b2..75731e80 100644 --- a/src/api/extractors/auth.rs +++ b/src/api/extractors/auth.rs @@ -174,7 +174,7 @@ pub struct AppState { /// Refresh-token issuer / validator / rotator. /// Always present; the [`AuthConfig::refresh_token_enabled`] flag gates /// whether handlers actually call `issue` on login. - pub refresh_token_service: Arc<crate::services::RefreshTokenService>, + pub refresh_token_service: Arc<codex_services::RefreshTokenService>, pub auth_config: Arc<codex_config::AuthConfig>, /// Database configuration - used for operation deadlines and pool settings pub database_config: Arc<codex_config::DatabaseConfig>, @@ -184,60 +184,60 @@ pub struct AppState { /// endpoint and the OTLP forwarding proxy. Always present; handlers gate /// behavior on `browser.enabled` / `otlp.endpoint`. pub observability_config: Arc<codex_config::ObservabilityConfig>, - pub email_service: Arc<crate::services::email::EmailService>, + pub email_service: Arc<codex_services::email::EmailService>, pub event_broadcaster: Arc<codex_events::EventBroadcaster>, /// Settings service - used for runtime configuration #[allow(dead_code)] - pub settings_service: Arc<crate::services::SettingsService>, - pub thumbnail_service: Arc<crate::services::ThumbnailService>, + pub settings_service: Arc<codex_services::SettingsService>, + pub thumbnail_service: Arc<codex_services::ThumbnailService>, /// File cleanup service for managing orphaned files - pub file_cleanup_service: Arc<crate::services::FileCleanupService>, + pub file_cleanup_service: Arc<codex_services::FileCleanupService>, /// Task metrics service for collecting task performance data /// None in test environments or when not needed - pub task_metrics_service: Option<Arc<crate::services::TaskMetricsService>>, + pub task_metrics_service: Option<Arc<codex_services::TaskMetricsService>>, /// Scheduler for managing scheduled tasks (library scans, deduplication, etc.) /// None when workers are disabled (CODEX_DISABLE_WORKERS=true) or in test environments pub scheduler: Option<Arc<tokio::sync::Mutex<crate::scheduler::Scheduler>>>, /// Read progress batching service for efficient page view tracking /// Batches progress updates in memory and flushes periodically to reduce DB load - pub read_progress_service: Arc<crate::services::ReadProgressService>, + pub read_progress_service: Arc<codex_services::ReadProgressService>, /// Auth tracking service for batched last_used/last_login timestamp updates /// Reduces DB load by batching API key usage and user login timestamps - pub auth_tracking_service: Arc<crate::services::AuthTrackingService>, + pub auth_tracking_service: Arc<codex_services::AuthTrackingService>, /// PDF page cache service for caching rendered PDF pages /// Reduces CPU load by caching expensive PDF page renders to disk - pub pdf_page_cache: Arc<crate::services::PdfPageCache>, + pub pdf_page_cache: Arc<codex_services::PdfPageCache>, /// PDF handle cache service for caching open PDFium document handles /// Avoids re-opening the same PDF on every page request; complements the /// on-disk JPEG cache by short-circuiting the cold-render path - pub pdf_handle_cache: Arc<crate::services::PdfHandleCache>, + pub pdf_handle_cache: Arc<codex_services::PdfHandleCache>, /// In-flight thumbnail request tracker to prevent thundering herd /// When multiple requests come in for the same uncached thumbnail, /// only the first generates it while others wait for the result - pub inflight_thumbnails: Arc<crate::services::InflightThumbnailTracker>, + pub inflight_thumbnails: Arc<codex_services::InflightThumbnailTracker>, /// User authentication cache to avoid hitting the database on every request /// Caches user permissions/role for 60 seconds to reduce DB load pub user_auth_cache: Arc<UserAuthCache>, /// Rate limiter service for API rate limiting /// None when rate limiting is disabled in config - pub rate_limiter_service: Option<Arc<crate::services::RateLimiterService>>, + pub rate_limiter_service: Option<Arc<codex_services::RateLimiterService>>, /// Plugin manager for coordinating external plugin processes /// Manages plugin lifecycle, spawning, and request routing - pub plugin_manager: Arc<crate::services::plugin::PluginManager>, + pub plugin_manager: Arc<codex_services::plugin::PluginManager>, /// Plugin metrics service for collecting plugin performance data /// Always available (in-memory only, no persistence) - pub plugin_metrics_service: Arc<crate::services::PluginMetricsService>, + pub plugin_metrics_service: Arc<codex_services::PluginMetricsService>, /// OIDC authentication service for external identity provider authentication /// None when OIDC is disabled in config - pub oidc_service: Option<Arc<crate::services::OidcService>>, + pub oidc_service: Option<Arc<codex_services::OidcService>>, /// OAuth state manager for user plugin OAuth flows - pub oauth_state_manager: Arc<crate::services::user_plugin::OAuthStateManager>, + pub oauth_state_manager: Arc<codex_services::user_plugin::OAuthStateManager>, /// Plugin file storage service for managing plugin data directories /// None when not configured (shouldn't happen in normal operation) - pub plugin_file_storage: Option<Arc<crate::services::PluginFileStorage>>, + pub plugin_file_storage: Option<Arc<codex_services::PluginFileStorage>>, /// Export storage service for managing series export files on disk /// None in test environments or when not configured - pub export_storage: Option<Arc<crate::services::ExportStorage>>, + pub export_storage: Option<Arc<codex_services::ExportStorage>>, /// Server-level default timezone for cron scheduling (IANA name, e.g. "America/Los_Angeles") pub scheduler_timezone: String, /// In-memory fuzzy search index over series and books. diff --git a/src/api/extractors/mod.rs b/src/api/extractors/mod.rs index b89cb388..eb7117c7 100644 --- a/src/api/extractors/mod.rs +++ b/src/api/extractors/mod.rs @@ -5,5 +5,5 @@ pub mod client_info; #[allow(unused_imports)] pub use auth::{AppState, AuthContext, AuthMethod, AuthState, FlexibleAuthContext}; pub use client_info::ClientInfo; -// Historical alias. The canonical location is `crate::services::content_filter`. -pub use crate::services::content_filter::ContentFilter; +// Historical alias. The canonical location is `codex_services::content_filter`. +pub use codex_services::content_filter::ContentFilter; diff --git a/src/api/middleware/rate_limit.rs b/src/api/middleware/rate_limit.rs index 81542d9d..f2d89440 100644 --- a/src/api/middleware/rate_limit.rs +++ b/src/api/middleware/rate_limit.rs @@ -41,7 +41,7 @@ use tower::{Layer, Service}; use tracing::{debug, trace}; use uuid::Uuid; -use crate::services::rate_limiter::{ClientId, RateLimiterService}; +use codex_services::rate_limiter::{ClientId, RateLimiterService}; /// Rate limit response headers const HEADER_RATE_LIMIT: &str = "X-RateLimit-Limit"; diff --git a/src/api/routes/v1/dto/book.rs b/src/api/routes/v1/dto/book.rs index f5364710..344bf8ea 100644 --- a/src/api/routes/v1/dto/book.rs +++ b/src/api/routes/v1/dto/book.rs @@ -2481,8 +2481,8 @@ pub struct BookContextDto { } // Conversion from internal BookContext to DTO -impl From<crate::services::metadata::preprocessing::context::BookContext> for BookContextDto { - fn from(ctx: crate::services::metadata::preprocessing::context::BookContext) -> Self { +impl From<codex_services::metadata::preprocessing::context::BookContext> for BookContextDto { + fn from(ctx: codex_services::metadata::preprocessing::context::BookContext) -> Self { Self { context_type: ctx.context_type, book_id: ctx.book_id, diff --git a/src/api/routes/v1/dto/cleanup.rs b/src/api/routes/v1/dto/cleanup.rs index 1593a4b7..32eb5168 100644 --- a/src/api/routes/v1/dto/cleanup.rs +++ b/src/api/routes/v1/dto/cleanup.rs @@ -71,8 +71,8 @@ pub struct CleanupResultDto { pub errors: Vec<String>, } -impl From<crate::services::file_cleanup::CleanupStats> for CleanupResultDto { - fn from(stats: crate::services::file_cleanup::CleanupStats) -> Self { +impl From<codex_services::file_cleanup::CleanupStats> for CleanupResultDto { + fn from(stats: codex_services::file_cleanup::CleanupStats) -> Self { Self { thumbnails_deleted: stats.thumbnails_deleted, covers_deleted: stats.covers_deleted, @@ -149,7 +149,7 @@ mod tests { #[test] fn test_cleanup_result_dto_from_stats() { - let stats = crate::services::file_cleanup::CleanupStats { + let stats = codex_services::file_cleanup::CleanupStats { thumbnails_deleted: 10, covers_deleted: 2, bytes_freed: 500_000, diff --git a/src/api/routes/v1/dto/library_jobs.rs b/src/api/routes/v1/dto/library_jobs.rs index f596be85..3b5ab335 100644 --- a/src/api/routes/v1/dto/library_jobs.rs +++ b/src/api/routes/v1/dto/library_jobs.rs @@ -7,7 +7,7 @@ use utoipa::ToSchema; use uuid::Uuid; use crate::api::routes::v1::dto::patch::PatchValue; -use crate::services::library_jobs::{LibraryJobConfig, MetadataRefreshJobConfig, RefreshScope}; +use codex_services::library_jobs::{LibraryJobConfig, MetadataRefreshJobConfig, RefreshScope}; /// Type-discriminated job config exposed over the wire. /// diff --git a/src/api/routes/v1/dto/pdf_cache.rs b/src/api/routes/v1/dto/pdf_cache.rs index b9fdec96..7219661d 100644 --- a/src/api/routes/v1/dto/pdf_cache.rs +++ b/src/api/routes/v1/dto/pdf_cache.rs @@ -41,8 +41,8 @@ pub struct PdfPageCacheStatsDto { pub cache_enabled: bool, } -impl From<crate::services::CacheStats> for PdfPageCacheStatsDto { - fn from(stats: crate::services::CacheStats) -> Self { +impl From<codex_services::CacheStats> for PdfPageCacheStatsDto { + fn from(stats: codex_services::CacheStats) -> Self { Self { total_files: stats.total_files, total_size_bytes: stats.total_size_bytes, @@ -80,8 +80,8 @@ pub struct PdfHandleCacheEntryDto { pub render_count: u64, } -impl From<crate::services::HandleCacheEntrySnapshot> for PdfHandleCacheEntryDto { - fn from(entry: crate::services::HandleCacheEntrySnapshot) -> Self { +impl From<codex_services::HandleCacheEntrySnapshot> for PdfHandleCacheEntryDto { + fn from(entry: codex_services::HandleCacheEntrySnapshot) -> Self { Self { book_id: entry.book_id, path: entry.path, @@ -139,8 +139,8 @@ pub struct PdfHandleCacheStatsDto { pub entries: Vec<PdfHandleCacheEntryDto>, } -impl From<crate::services::HandleCacheSnapshot> for PdfHandleCacheStatsDto { - fn from(snap: crate::services::HandleCacheSnapshot) -> Self { +impl From<codex_services::HandleCacheSnapshot> for PdfHandleCacheStatsDto { + fn from(snap: codex_services::HandleCacheSnapshot) -> Self { Self { enabled: snap.enabled, capacity: snap.capacity as u64, @@ -186,8 +186,8 @@ pub struct PdfCacheCleanupResultDto { pub bytes_reclaimed_human: String, } -impl From<crate::services::CleanupResult> for PdfCacheCleanupResultDto { - fn from(result: crate::services::CleanupResult) -> Self { +impl From<codex_services::CleanupResult> for PdfCacheCleanupResultDto { + fn from(result: codex_services::CleanupResult) -> Self { Self { files_deleted: result.files_deleted, bytes_reclaimed: result.bytes_reclaimed, diff --git a/src/api/routes/v1/dto/plugin_storage.rs b/src/api/routes/v1/dto/plugin_storage.rs index 19c5f67c..2b4bfda9 100644 --- a/src/api/routes/v1/dto/plugin_storage.rs +++ b/src/api/routes/v1/dto/plugin_storage.rs @@ -20,8 +20,8 @@ pub struct PluginStorageStatsDto { pub total_bytes: u64, } -impl From<crate::services::PluginStorageStats> for PluginStorageStatsDto { - fn from(stats: crate::services::PluginStorageStats) -> Self { +impl From<codex_services::PluginStorageStats> for PluginStorageStatsDto { + fn from(stats: codex_services::PluginStorageStats) -> Self { Self { plugin_name: stats.plugin_name, file_count: stats.file_count, @@ -67,8 +67,8 @@ pub struct PluginCleanupResultDto { pub errors: Vec<String>, } -impl From<crate::services::PluginCleanupStats> for PluginCleanupResultDto { - fn from(stats: crate::services::PluginCleanupStats) -> Self { +impl From<codex_services::PluginCleanupStats> for PluginCleanupResultDto { + fn from(stats: codex_services::PluginCleanupStats) -> Self { Self { files_deleted: stats.files_deleted, bytes_freed: stats.bytes_freed, diff --git a/src/api/routes/v1/dto/plugins.rs b/src/api/routes/v1/dto/plugins.rs index dba7d7ee..afa8e1b0 100644 --- a/src/api/routes/v1/dto/plugins.rs +++ b/src/api/routes/v1/dto/plugins.rs @@ -8,12 +8,12 @@ use serde::{Deserialize, Serialize}; use utoipa::ToSchema; use uuid::Uuid; -use crate::services::plugin::protocol::{ - CredentialField, MetadataContentType, PluginCapabilities, PluginScope, -}; use codex_db::entities::plugin_failures; use codex_db::entities::plugins::{self, InternalPluginConfig, PluginPermission}; use codex_db::repositories::PluginsRepository; +use codex_services::plugin::protocol::{ + CredentialField, MetadataContentType, PluginCapabilities, PluginScope, +}; use super::common::deserialize_optional_nullable; @@ -285,8 +285,8 @@ pub struct OAuthConfigDto { pub user_info_url: Option<String>, } -impl From<crate::services::plugin::protocol::OAuthConfig> for OAuthConfigDto { - fn from(o: crate::services::plugin::protocol::OAuthConfig) -> Self { +impl From<codex_services::plugin::protocol::OAuthConfig> for OAuthConfigDto { + fn from(o: codex_services::plugin::protocol::OAuthConfig) -> Self { Self { authorization_url: o.authorization_url, token_url: o.token_url, @@ -346,8 +346,8 @@ pub struct PluginManifestDto { pub search_uri_template: Option<String>, } -impl From<crate::services::plugin::protocol::PluginManifest> for PluginManifestDto { - fn from(m: crate::services::plugin::protocol::PluginManifest) -> Self { +impl From<codex_services::plugin::protocol::PluginManifest> for PluginManifestDto { + fn from(m: codex_services::plugin::protocol::PluginManifest) -> Self { // Derive content types from capabilities let content_types: Vec<String> = m .capabilities @@ -459,9 +459,9 @@ pub struct CredentialFieldDto { impl From<CredentialField> for CredentialFieldDto { fn from(f: CredentialField) -> Self { let credential_type = match f.credential_type { - crate::services::plugin::protocol::CredentialType::String => "string", - crate::services::plugin::protocol::CredentialType::Password => "password", - crate::services::plugin::protocol::CredentialType::OAuth => "oauth", + codex_services::plugin::protocol::CredentialType::String => "string", + codex_services::plugin::protocol::CredentialType::Password => "password", + codex_services::plugin::protocol::CredentialType::OAuth => "oauth", }; Self { key: f.key, @@ -1491,8 +1491,8 @@ pub struct EnqueueAutoMatchResponse { // Conversions from Protocol Types // ============================================================================= -impl From<crate::services::plugin::protocol::SearchResult> for PluginSearchResultDto { - fn from(r: crate::services::plugin::protocol::SearchResult) -> Self { +impl From<codex_services::plugin::protocol::SearchResult> for PluginSearchResultDto { + fn from(r: codex_services::plugin::protocol::SearchResult) -> Self { Self { external_id: r.external_id, title: r.title, @@ -1505,8 +1505,8 @@ impl From<crate::services::plugin::protocol::SearchResult> for PluginSearchResul } } -impl From<crate::services::plugin::protocol::SearchResultPreview> for SearchResultPreviewDto { - fn from(p: crate::services::plugin::protocol::SearchResultPreview) -> Self { +impl From<codex_services::plugin::protocol::SearchResultPreview> for SearchResultPreviewDto { + fn from(p: codex_services::plugin::protocol::SearchResultPreview) -> Self { Self { status: p.status, genres: p.genres, diff --git a/src/api/routes/v1/dto/release.rs b/src/api/routes/v1/dto/release.rs index 071bbd3a..e17c392d 100644 --- a/src/api/routes/v1/dto/release.rs +++ b/src/api/routes/v1/dto/release.rs @@ -213,7 +213,7 @@ impl ReleaseSourceDto { /// schedule. Use this in handlers that already have the default in /// hand (avoids a settings round-trip per row). pub fn from_model_with_default(m: release_sources::Model, server_default: &str) -> Self { - let effective = crate::services::release::schedule::resolve_cron_schedule( + let effective = codex_services::release::schedule::resolve_cron_schedule( m.cron_schedule.as_deref(), server_default, ); @@ -244,7 +244,7 @@ impl From<release_sources::Model> for ReleaseSourceDto { /// `DEFAULT_CRON_SCHEDULE` for resolution. Production handlers should /// prefer [`ReleaseSourceDto::from_model_with_default`]. fn from(m: release_sources::Model) -> Self { - Self::from_model_with_default(m, crate::services::release::schedule::DEFAULT_CRON_SCHEDULE) + Self::from_model_with_default(m, codex_services::release::schedule::DEFAULT_CRON_SCHEDULE) } } diff --git a/src/api/routes/v1/dto/series.rs b/src/api/routes/v1/dto/series.rs index a7424932..cff9c91a 100644 --- a/src/api/routes/v1/dto/series.rs +++ b/src/api/routes/v1/dto/series.rs @@ -1840,8 +1840,8 @@ pub struct SeriesContextDto { } // Conversion from internal SeriesContext to DTO -impl From<crate::services::metadata::preprocessing::context::SeriesContext> for SeriesContextDto { - fn from(ctx: crate::services::metadata::preprocessing::context::SeriesContext) -> Self { +impl From<codex_services::metadata::preprocessing::context::SeriesContext> for SeriesContextDto { + fn from(ctx: codex_services::metadata::preprocessing::context::SeriesContext) -> Self { Self { context_type: ctx.context_type, series_id: ctx.series_id, diff --git a/src/api/routes/v1/handlers/auth.rs b/src/api/routes/v1/handlers/auth.rs index 3a4dc230..aa0976c2 100644 --- a/src/api/routes/v1/handlers/auth.rs +++ b/src/api/routes/v1/handlers/auth.rs @@ -8,7 +8,6 @@ use crate::api::{ extractors::{AuthContext, AuthState, ClientInfo, FlexibleAuthContext}, permissions::UserRole, // Used for creating users with default role }; -use crate::services::RefreshTokenError; use axum::{ Json, extract::State, @@ -20,6 +19,7 @@ use codex_db::{ entities::users, repositories::{EmailVerificationTokenRepository, SettingsRepository, UserRepository}, }; +use codex_services::RefreshTokenError; use codex_utils::password; use sea_orm::ActiveModelTrait; use sea_orm::Set; diff --git a/src/api/routes/v1/handlers/books.rs b/src/api/routes/v1/handlers/books.rs index 01a60c46..fb71a7bb 100644 --- a/src/api/routes/v1/handlers/books.rs +++ b/src/api/routes/v1/handlers/books.rs @@ -19,7 +19,6 @@ use crate::api::{ permissions::Permission, }; use crate::require_permission; -use crate::services::FilterService; use axum::{ Json, body::Body, @@ -31,6 +30,7 @@ use codex_db::repositories::{ BookMetadataRepository, BookRepository, GenreRepository, LibraryRepository, PageRepository, ReadProgressRepository, SeriesMetadataRepository, TagRepository, }; +use codex_services::FilterService; use codex_utils::{ json_merge_patch, normalize_for_search, parse_custom_metadata, serialize_custom_metadata, validate_custom_metadata_size, diff --git a/src/api/routes/v1/handlers/cleanup.rs b/src/api/routes/v1/handlers/cleanup.rs index 1d6ebcc9..7e318381 100644 --- a/src/api/routes/v1/handlers/cleanup.rs +++ b/src/api/routes/v1/handlers/cleanup.rs @@ -18,9 +18,9 @@ use crate::api::{ permissions::Permission, }; use crate::require_permission; -use crate::services::file_cleanup::OrphanedFileType; use crate::tasks::types::TaskType; use codex_db::repositories::{BookRepository, SeriesRepository, TaskRepository}; +use codex_services::file_cleanup::OrphanedFileType; /// Get statistics about orphaned files /// diff --git a/src/api/routes/v1/handlers/library_jobs.rs b/src/api/routes/v1/handlers/library_jobs.rs index f34a9d6b..10d74883 100644 --- a/src/api/routes/v1/handlers/library_jobs.rs +++ b/src/api/routes/v1/handlers/library_jobs.rs @@ -15,15 +15,15 @@ use crate::api::{ permissions::Permission, }; use crate::require_permission; -use crate::services::library_jobs::{ - LibraryJobConfig, MetadataRefreshJobConfig, parse_job_config, validation, -}; -use crate::services::metadata::{FieldGroup, RefreshPlanner, fields_for_group}; use crate::tasks::types::TaskType; use codex_db::entities::library_jobs; use codex_db::repositories::{ CreateLibraryJobParams, LibraryJobRepository, LibraryRepository, SeriesRepository, }; +use codex_services::library_jobs::{ + LibraryJobConfig, MetadataRefreshJobConfig, parse_job_config, validation, +}; +use codex_services::metadata::{FieldGroup, RefreshPlanner, fields_for_group}; use super::super::dto::patch::PatchValue; use super::super::dto::{ @@ -414,8 +414,8 @@ pub async fn dry_run_job( let mut est_skipped_recently = 0u32; for s in &plan.skipped { match s.reason { - crate::services::metadata::SkipReason::NoExternalId => est_skipped_no_id += 1, - crate::services::metadata::SkipReason::RecentlySynced { .. } => { + codex_services::metadata::SkipReason::NoExternalId => est_skipped_no_id += 1, + codex_services::metadata::SkipReason::RecentlySynced { .. } => { est_skipped_recently += 1 } } @@ -506,7 +506,7 @@ fn human_label(g: FieldGroup) -> &'static str { #[cfg(test)] mod tests { use super::*; - use crate::services::library_jobs::RefreshScope; + use codex_services::library_jobs::RefreshScope; #[test] fn auto_name_uses_provider_and_groups() { diff --git a/src/api/routes/v1/handlers/plugin_actions.rs b/src/api/routes/v1/handlers/plugin_actions.rs index af07bbf8..c58910d9 100644 --- a/src/api/routes/v1/handlers/plugin_actions.rs +++ b/src/api/routes/v1/handlers/plugin_actions.rs @@ -20,17 +20,6 @@ use super::super::dto::{ PreviewSummary, SearchTitleResponse, SkippedField, parse_scope, }; use crate::api::{AppState, error::ApiError, extractors::AuthContext, permissions::Permission}; -use crate::services::metadata::preprocessing::{ - PreprocessingRule, SeriesContextBuilder, apply_rules, render_template, -}; -use crate::services::metadata::{ - ApplyOptions, BookApplyOptions, BookMetadataApplier, MetadataApplier, -}; -use crate::services::plugin::PluginManagerError; -use crate::services::plugin::protocol::{ - BookMatchParams, BookSearchParams, MetadataContentType, MetadataGetParams, MetadataMatchParams, - MetadataSearchParams, PluginScope, -}; use crate::tasks::types::TaskType; use axum::{ Json, @@ -43,6 +32,17 @@ use codex_db::repositories::{ PluginsRepository, SeriesExternalIdRepository, SeriesMetadataRepository, SeriesRepository, TagRepository, TaskRepository, }; +use codex_services::metadata::preprocessing::{ + PreprocessingRule, SeriesContextBuilder, apply_rules, render_template, +}; +use codex_services::metadata::{ + ApplyOptions, BookApplyOptions, BookMetadataApplier, MetadataApplier, +}; +use codex_services::plugin::PluginManagerError; +use codex_services::plugin::protocol::{ + BookMatchParams, BookSearchParams, MetadataContentType, MetadataGetParams, MetadataMatchParams, + MetadataSearchParams, PluginScope, +}; use sea_orm::prelude::Decimal; use serde::Deserialize; use std::collections::{HashMap, HashSet}; @@ -2541,9 +2541,9 @@ fn sanitize_plugin_error(error: &PluginManagerError) -> String { /// /// Since the nested error types (PluginError, RpcError) are not part of the public API, /// we pattern match on the error string to provide user-friendly messages. -fn sanitize_nested_plugin_error(error: &crate::services::plugin::handle::PluginError) -> String { - use crate::services::plugin::handle::PluginError; - use crate::services::plugin::rpc::RpcError; +fn sanitize_nested_plugin_error(error: &codex_services::plugin::handle::PluginError) -> String { + use codex_services::plugin::handle::PluginError; + use codex_services::plugin::rpc::RpcError; match error { PluginError::NotInitialized => "Plugin is not ready, please try again".to_string(), diff --git a/src/api/routes/v1/handlers/plugins.rs b/src/api/routes/v1/handlers/plugins.rs index 79779103..957fdd3d 100644 --- a/src/api/routes/v1/handlers/plugins.rs +++ b/src/api/routes/v1/handlers/plugins.rs @@ -11,9 +11,6 @@ use super::super::dto::{ parse_permission, parse_scope, }; use crate::api::{AppState, error::ApiError, extractors::AuthContext, permissions::Permission}; -use crate::services::PluginHealthStatus; -use crate::services::plugin::process::{allowed_commands_description, is_command_allowed}; -use crate::services::plugin::protocol::PluginScope; use axum::{ Json, extract::{Path, State}, @@ -22,6 +19,9 @@ use axum::{ use codex_db::entities::plugins::{InternalPluginConfig, PluginPermission}; use codex_db::repositories::{PluginFailuresRepository, PluginsRepository, UserPluginsRepository}; use codex_events::{EntityChangeEvent, EntityEvent}; +use codex_services::PluginHealthStatus; +use codex_services::plugin::process::{allowed_commands_description, is_command_allowed}; +use codex_services::plugin::protocol::PluginScope; use std::sync::Arc; use std::time::Instant; use utoipa::OpenApi; diff --git a/src/api/routes/v1/handlers/recommendations.rs b/src/api/routes/v1/handlers/recommendations.rs index be245942..d52fe7e5 100644 --- a/src/api/routes/v1/handlers/recommendations.rs +++ b/src/api/routes/v1/handlers/recommendations.rs @@ -10,8 +10,6 @@ use super::super::dto::recommendations::{ }; use crate::api::extractors::auth::AuthContext; use crate::api::{error::ApiError, extractors::AppState}; -use crate::services::plugin::protocol::PluginManifest; -use crate::services::plugin::recommendations::RecommendationResponse; use crate::tasks::types::TaskType; use axum::{ Json, @@ -22,6 +20,8 @@ use codex_db::repositories::{ PluginsRepository, SeriesExternalIdRepository, TaskRepository, UserPluginDataRepository, UserPluginsRepository, }; +use codex_services::plugin::protocol::PluginManifest; +use codex_services::plugin::recommendations::RecommendationResponse; use std::sync::Arc; use tracing::{debug, info, warn}; use uuid::Uuid; @@ -354,7 +354,7 @@ async fn enrich_and_filter_codex_presence( /// This is extracted for testability — the handler maps the plugin's response /// into the API response type field-by-field. fn to_recommendation_dto( - r: crate::services::plugin::recommendations::Recommendation, + r: codex_services::plugin::recommendations::Recommendation, ) -> RecommendationDto { use super::super::dto::recommendations::RecommendationTagDto; @@ -489,10 +489,10 @@ pub async fn dismiss_recommendation( mod tests { use super::*; use crate::api::error::ApiError; - use crate::services::plugin::handle::PluginError; - use crate::services::plugin::process::ProcessError; - use crate::services::plugin::recommendations::Recommendation; - use crate::services::plugin::rpc::RpcError; + use codex_services::plugin::handle::PluginError; + use codex_services::plugin::process::ProcessError; + use codex_services::plugin::recommendations::Recommendation; + use codex_services::plugin::rpc::RpcError; use std::time::Duration; /// Map a `PluginError` to the appropriate `ApiError` with proper HTTP status codes. @@ -755,7 +755,7 @@ mod tests { /// and the GET endpoint (read from DB → deserialize from Value). #[test] fn test_recommendation_response_round_trip_through_json_value() { - use crate::services::plugin::recommendations::RecommendationResponse; + use codex_services::plugin::recommendations::RecommendationResponse; let original = RecommendationResponse { recommendations: vec![Recommendation { @@ -804,7 +804,7 @@ mod tests { /// This covers the case where a plugin returns zero recommendations. #[test] fn test_empty_recommendation_response_round_trip() { - use crate::services::plugin::recommendations::RecommendationResponse; + use codex_services::plugin::recommendations::RecommendationResponse; let original = RecommendationResponse { recommendations: vec![], diff --git a/src/api/routes/v1/handlers/releases.rs b/src/api/routes/v1/handlers/releases.rs index 072afe54..e8df628c 100644 --- a/src/api/routes/v1/handlers/releases.rs +++ b/src/api/routes/v1/handlers/releases.rs @@ -882,8 +882,8 @@ pub async fn list_release_sources( /// rather than 500-ing the request — the field is informational on the /// response shape. async fn resolve_server_default_cron(db: &sea_orm::DatabaseConnection) -> String { - use crate::services::release::schedule::{DEFAULT_CRON_SCHEDULE, read_default_cron_schedule}; - use crate::services::settings::SettingsService; + use codex_services::release::schedule::{DEFAULT_CRON_SCHEDULE, read_default_cron_schedule}; + use codex_services::settings::SettingsService; match SettingsService::new(db.clone()).await { Ok(svc) => read_default_cron_schedule(&svc).await, Err(e) => { @@ -1330,9 +1330,9 @@ pub async fn get_release_tracking_applicability( let Some(manifest_json) = plugin.manifest.as_ref() else { continue; }; - let Ok(manifest) = serde_json::from_value::< - crate::services::plugin::protocol::PluginManifest, - >(manifest_json.clone()) else { + let Ok(manifest) = serde_json::from_value::<codex_services::plugin::protocol::PluginManifest>( + manifest_json.clone(), + ) else { continue; }; if manifest.capabilities.release_source.is_none() { diff --git a/src/api/routes/v1/handlers/series.rs b/src/api/routes/v1/handlers/series.rs index 7d4e7a95..a77760a7 100644 --- a/src/api/routes/v1/handlers/series.rs +++ b/src/api/routes/v1/handlers/series.rs @@ -27,9 +27,6 @@ use crate::api::{ permissions::Permission, }; use crate::require_permission; -use crate::services::release::upstream_gap::{ - UpstreamGap, UpstreamGapInputs, compute_upstream_gap, -}; use axum::{ Json, body::Body, @@ -46,6 +43,7 @@ use codex_db::repositories::{ SeriesTrackingRepository, SharingTagRepository, TagRepository, UserSeriesRatingRepository, }; use codex_events::{EntityChangeEvent, EntityEvent, EntityType}; +use codex_services::release::upstream_gap::{UpstreamGap, UpstreamGapInputs, compute_upstream_gap}; use codex_utils::{ json_merge_patch, normalize_for_search, parse_custom_metadata, serialize_custom_metadata, validate_custom_metadata_size, @@ -1185,7 +1183,7 @@ pub async fn list_series_filtered( Json(request): Json<SeriesListRequest>, ) -> Result<Response, ApiError> { use crate::api::routes::v1::dto::series::{SeriesSortField, SortDirection}; - use crate::services::FilterService; + use codex_services::FilterService; use std::collections::HashSet; require_permission!(auth, Permission::SeriesRead)?; @@ -1417,7 +1415,7 @@ pub async fn list_series_alphabetical_groups( auth: AuthContext, Json(request): Json<SeriesListRequest>, ) -> Result<Json<Vec<AlphabeticalGroupDto>>, ApiError> { - use crate::services::FilterService; + use codex_services::FilterService; use std::collections::HashMap; require_permission!(auth, Permission::SeriesRead)?; diff --git a/src/api/routes/v1/handlers/series_exports.rs b/src/api/routes/v1/handlers/series_exports.rs index 16be85da..ea760368 100644 --- a/src/api/routes/v1/handlers/series_exports.rs +++ b/src/api/routes/v1/handlers/series_exports.rs @@ -12,10 +12,10 @@ use uuid::Uuid; use crate::api::error::ApiError; use crate::api::extractors::auth::{AppState, AuthContext}; -use crate::services::book_export_collector::BookExportField; -use crate::services::series_export_collector::ExportField; use crate::tasks::types::TaskType; use codex_db::repositories::{SeriesExportRepository, TaskRepository}; +use codex_services::book_export_collector::BookExportField; +use codex_services::series_export_collector::ExportField; use super::super::dto::series_export::{ CreateSeriesExportRequest, ExportFieldCatalogResponse, ExportFieldDto, ExportPresetsDto, diff --git a/src/api/routes/v1/handlers/task_queue.rs b/src/api/routes/v1/handlers/task_queue.rs index 8c471cc8..8df29a67 100644 --- a/src/api/routes/v1/handlers/task_queue.rs +++ b/src/api/routes/v1/handlers/task_queue.rs @@ -1032,7 +1032,7 @@ pub async fn reprocess_series_title( Path(series_id): Path<Uuid>, Json(request): Json<EnqueueReprocessTitleRequest>, ) -> Result<Json<EnqueueReprocessTitleResponse>, ApiError> { - use crate::services::metadata::preprocessing::apply_rules; + use codex_services::metadata::preprocessing::apply_rules; auth.require_permission(&Permission::SeriesWrite)?; @@ -1157,7 +1157,7 @@ pub async fn reprocess_library_series_titles( Path(library_id): Path<Uuid>, Json(request): Json<ReprocessTitleRequest>, ) -> Result<Json<EnqueueReprocessTitleResponse>, ApiError> { - use crate::services::metadata::preprocessing::apply_rules; + use codex_services::metadata::preprocessing::apply_rules; auth.require_permission(&Permission::LibrariesWrite)?; diff --git a/src/api/routes/v1/handlers/tracking.rs b/src/api/routes/v1/handlers/tracking.rs index 38c7f602..aaa3fa79 100644 --- a/src/api/routes/v1/handlers/tracking.rs +++ b/src/api/routes/v1/handlers/tracking.rs @@ -26,12 +26,12 @@ use crate::api::{ permissions::Permission, }; use crate::require_permission; -use crate::services::release::seed::seed_tracking_for_series; use codex_db::entities::series_aliases::alias_source; use codex_db::repositories::{ SeriesAliasRepository, SeriesRepository, SeriesTrackingRepository, TrackingUpdate, }; use codex_events::{EntityChangeEvent, EntityEvent}; +use codex_services::release::seed::seed_tracking_for_series; // ============================================================================= // Tracking config handlers diff --git a/src/api/routes/v1/handlers/user_plugins.rs b/src/api/routes/v1/handlers/user_plugins.rs index 719461be..f770a0b4 100644 --- a/src/api/routes/v1/handlers/user_plugins.rs +++ b/src/api/routes/v1/handlers/user_plugins.rs @@ -13,8 +13,6 @@ use super::super::dto::user_plugins::{ }; use crate::api::extractors::auth::AuthContext; use crate::api::{error::ApiError, extractors::AppState}; -use crate::services::plugin::protocol::{OAuthConfig, PluginManifest, methods}; -use crate::services::plugin::sync::SyncStatusResponse; use crate::tasks::handlers::user_plugin_sync::LAST_SYNC_RESULT_KEY; use crate::tasks::types::TaskType; use axum::{ @@ -25,6 +23,8 @@ use axum::{ use codex_db::repositories::{ PluginsRepository, TaskRepository, UserPluginDataRepository, UserPluginsRepository, }; +use codex_services::plugin::protocol::{OAuthConfig, PluginManifest, methods}; +use codex_services::plugin::sync::SyncStatusResponse; use std::sync::Arc; use tracing::{debug, info, warn}; use uuid::Uuid; diff --git a/src/commands/common.rs b/src/commands/common.rs index d824534a..54b98b36 100644 --- a/src/commands/common.rs +++ b/src/commands/common.rs @@ -1,9 +1,9 @@ use crate::observability::ObservabilityHandle; -use crate::services::{SettingsService, TaskMetricsService}; use crate::tasks::TaskWorker; use codex_config::{Config, DatabaseConfig, DatabaseType, EnvOverride}; use codex_db::Database; use codex_events::EventBroadcaster; +use codex_services::{SettingsService, TaskMetricsService}; use sea_orm::DatabaseConnection; use std::fs; use std::path::{Path, PathBuf}; @@ -467,14 +467,14 @@ pub fn spawn_workers( worker_count: u32, event_broadcaster: Arc<EventBroadcaster>, settings_service: Arc<SettingsService>, - thumbnail_service: Arc<crate::services::ThumbnailService>, + thumbnail_service: Arc<codex_services::ThumbnailService>, task_metrics_service: Option<Arc<TaskMetricsService>>, files_config: codex_config::FilesConfig, - pdf_page_cache: Option<Arc<crate::services::PdfPageCache>>, - pdf_handle_cache: Option<Arc<crate::services::PdfHandleCache>>, - plugin_manager: Option<Arc<crate::services::plugin::PluginManager>>, - oauth_state_manager: Option<Arc<crate::services::user_plugin::OAuthStateManager>>, - export_storage: Arc<crate::services::ExportStorage>, + pdf_page_cache: Option<Arc<codex_services::PdfPageCache>>, + pdf_handle_cache: Option<Arc<codex_services::PdfHandleCache>>, + plugin_manager: Option<Arc<codex_services::plugin::PluginManager>>, + oauth_state_manager: Option<Arc<codex_services::user_plugin::OAuthStateManager>>, + export_storage: Arc<codex_services::ExportStorage>, ) -> ( Vec<tokio::task::JoinHandle<()>>, Vec<tokio::sync::broadcast::Sender<()>>, @@ -583,9 +583,9 @@ pub async fn shutdown_workers( #[cfg(test)] mod tests { use super::*; - use crate::services::SettingsService; use codex_config::{FilesConfig, SQLiteConfig, TaskConfig}; use codex_db::test_helpers::create_test_db; + use codex_services::SettingsService; use tempfile::TempDir; #[test] diff --git a/src/commands/seed.rs b/src/commands/seed.rs index 537a636d..9f58b6cc 100644 --- a/src/commands/seed.rs +++ b/src/commands/seed.rs @@ -1,7 +1,6 @@ use crate::api::permissions::{ ADMIN_PERMISSIONS, MAINTAINER_PERMISSIONS, READER_PERMISSIONS, serialize_permissions, }; -use crate::services::plugin::protocol::PluginScope; use anyhow::{Context, Result}; use chrono::Utc; use codex_config::{Config, EnvOverride}; @@ -12,6 +11,7 @@ use codex_db::repositories::{ plugins::PluginsRepository, user::UserRepository, }; use codex_models::{BookStrategy, NumberStrategy, SeriesStrategy}; +use codex_services::plugin::protocol::PluginScope; use codex_utils::password::hash_password; use rand::RngExt; use serde::Deserialize; diff --git a/src/commands/serve.rs b/src/commands/serve.rs index 479db1eb..096f92c3 100644 --- a/src/commands/serve.rs +++ b/src/commands/serve.rs @@ -61,7 +61,7 @@ pub async fn serve_command(config_path: PathBuf) -> anyhow::Result<()> { info!("Event broadcaster initialized"); // Start cleanup event subscriber to handle file cleanup on entity deletion - let cleanup_subscriber = crate::services::CleanupEventSubscriber::new( + let cleanup_subscriber = codex_services::CleanupEventSubscriber::new( db.sea_orm_connection().clone(), event_broadcaster.clone(), ); @@ -72,7 +72,7 @@ pub async fn serve_command(config_path: PathBuf) -> anyhow::Result<()> { // This allows workers in separate containers to notify the web server of task completions if config.database.db_type == DatabaseType::Postgres { info!("Starting PostgreSQL task listener for cross-container notifications..."); - match crate::services::TaskListener::from_sea_orm( + match codex_services::TaskListener::from_sea_orm( db.sea_orm_connection(), event_broadcaster.clone(), ) { @@ -100,7 +100,7 @@ pub async fn serve_command(config_path: PathBuf) -> anyhow::Result<()> { .unwrap_or(false); // Initialize thumbnail service (needed for both workers, API handlers, and scheduler) - let thumbnail_service = Arc::new(crate::services::ThumbnailService::new(config.files.clone())); + let thumbnail_service = Arc::new(codex_services::ThumbnailService::new(config.files.clone())); info!( "Files service initialized (thumbnails: {}, uploads: {})", config.files.thumbnail_dir, config.files.uploads_dir @@ -120,12 +120,12 @@ pub async fn serve_command(config_path: PathBuf) -> anyhow::Result<()> { info!("Job scheduler started successfully"); // Initialize file cleanup service (for orphaned file cleanup via API) - let file_cleanup_service = Arc::new(crate::services::FileCleanupService::new( + let file_cleanup_service = Arc::new(codex_services::FileCleanupService::new( config.files.clone(), )); // Initialize task metrics service - let task_metrics_service = Arc::new(crate::services::TaskMetricsService::new( + let task_metrics_service = Arc::new(codex_services::TaskMetricsService::new( db.sea_orm_connection().clone(), settings_service.clone(), )); @@ -147,7 +147,7 @@ pub async fn serve_command(config_path: PathBuf) -> anyhow::Result<()> { ); // Initialize read progress batching service - let read_progress_service = Arc::new(crate::services::ReadProgressService::new( + let read_progress_service = Arc::new(codex_services::ReadProgressService::new( db.sea_orm_connection().clone(), )); info!("Read progress batching service initialized"); @@ -159,7 +159,7 @@ pub async fn serve_command(config_path: PathBuf) -> anyhow::Result<()> { info!("Read progress background flush started (5s interval)"); // Initialize auth tracking batching service - let auth_tracking_service = Arc::new(crate::services::AuthTrackingService::new( + let auth_tracking_service = Arc::new(codex_services::AuthTrackingService::new( db.sea_orm_connection().clone(), )); info!("Auth tracking batching service initialized"); @@ -191,7 +191,7 @@ pub async fn serve_command(config_path: PathBuf) -> anyhow::Result<()> { } // Initialize PDF page cache service - let pdf_page_cache = Arc::new(crate::services::PdfPageCache::new( + let pdf_page_cache = Arc::new(codex_services::PdfPageCache::new( &config.pdf.cache_dir, config.pdf.cache_rendered_pages, )); @@ -209,7 +209,7 @@ pub async fn serve_command(config_path: PathBuf) -> anyhow::Result<()> { // task. The cache stays empty until the page handler wires `get_or_open` // into the render miss path. let handle_cache_cfg = &config.pdf_handle_cache; - let pdf_handle_cache = Arc::new(crate::services::PdfHandleCache::new( + let pdf_handle_cache = Arc::new(codex_services::PdfHandleCache::new( handle_cache_cfg.capacity, std::time::Duration::from_secs(handle_cache_cfg.idle_ttl_minutes * 60), handle_cache_cfg.enabled, @@ -234,7 +234,7 @@ pub async fn serve_command(config_path: PathBuf) -> anyhow::Result<()> { // stale handles automatically. Covers BookUpdated (analyzer, manual edits, // scanner soft-delete/restore) and BookDeleted (purge paths). let _pdf_handle_cache_subscriber_handle = if handle_cache_cfg.enabled { - let subscriber = crate::services::PdfHandleCacheSubscriber::new( + let subscriber = codex_services::PdfHandleCacheSubscriber::new( pdf_handle_cache.clone(), event_broadcaster.clone(), ); @@ -245,7 +245,7 @@ pub async fn serve_command(config_path: PathBuf) -> anyhow::Result<()> { // Initialize rate limiter service if enabled let rate_limiter_service = if config.rate_limit.enabled { - let service = Arc::new(crate::services::RateLimiterService::new(Arc::new( + let service = Arc::new(codex_services::RateLimiterService::new(Arc::new( config.rate_limit.clone(), ))); info!( @@ -275,7 +275,7 @@ pub async fn serve_command(config_path: PathBuf) -> anyhow::Result<()> { if email_config.verification_url_base.is_none() { email_config.verification_url_base = Some(config.application.effective_base_url()); } - let email_service = Arc::new(crate::services::email::EmailService::new(email_config)); + let email_service = Arc::new(codex_services::email::EmailService::new(email_config)); info!(" SMTP host: {}", config.email.smtp_host); info!(" SMTP port: {}", config.email.smtp_port); info!(" From: {}", config.email.smtp_from_email); @@ -295,7 +295,7 @@ pub async fn serve_command(config_path: PathBuf) -> anyhow::Result<()> { config.auth.oidc.auto_create_users ); info!(" Default role: {}", config.auth.oidc.default_role.as_str()); - let service = crate::services::OidcService::new(config.auth.oidc.clone(), base_url.clone()); + let service = codex_services::OidcService::new(config.auth.oidc.clone(), base_url.clone()); let provider_count = service.get_providers().len(); info!(" Providers: {}", provider_count); for (name, provider_config) in &config.auth.oidc.providers { @@ -324,11 +324,11 @@ pub async fn serve_command(config_path: PathBuf) -> anyhow::Result<()> { // Initialize plugin metrics service info!("Initializing plugin metrics service..."); - let plugin_metrics_service = Arc::new(crate::services::PluginMetricsService::new()); + let plugin_metrics_service = Arc::new(codex_services::PluginMetricsService::new()); info!("Plugin metrics service initialized"); // Initialize plugin file storage (shared between plugin manager and app state) - let plugin_file_storage = Arc::new(crate::services::PluginFileStorage::new( + let plugin_file_storage = Arc::new(codex_services::PluginFileStorage::new( &config.files.plugins_dir, )); @@ -341,11 +341,11 @@ pub async fn serve_command(config_path: PathBuf) -> anyhow::Result<()> { info!("Initializing plugin manager..."); // Wrap the scheduler in the services-layer trait so plugin handles can // trigger reconciles without holding the concrete scheduler type. - let scheduler_handle: crate::services::scheduler_handle::SharedSchedulerReconciler = Arc::new( + let scheduler_handle: codex_services::scheduler_handle::SharedSchedulerReconciler = Arc::new( crate::scheduler::LockedSchedulerReconciler::new(scheduler.clone()), ); let plugin_manager = Arc::new( - crate::services::plugin::PluginManager::with_defaults(Arc::new( + codex_services::plugin::PluginManager::with_defaults(Arc::new( db.sea_orm_connection().clone(), )) .with_metrics_service(plugin_metrics_service.clone()) @@ -362,17 +362,17 @@ pub async fn serve_command(config_path: PathBuf) -> anyhow::Result<()> { info!(" Plugin health checks started (60s interval)"); // Initialize OAuth state manager (shared between API and workers for cleanup) - let oauth_state_manager = Arc::new(crate::services::user_plugin::OAuthStateManager::new()); + let oauth_state_manager = Arc::new(codex_services::user_plugin::OAuthStateManager::new()); // Create export storage for series export tasks (shared between workers and API) let exports_dir = settings_service .get_string( "exports.dir", - crate::services::export_storage::DEFAULT_EXPORTS_DIR, + codex_services::export_storage::DEFAULT_EXPORTS_DIR, ) .await - .unwrap_or_else(|_| crate::services::export_storage::DEFAULT_EXPORTS_DIR.to_string()); - let export_storage = Arc::new(crate::services::ExportStorage::new(exports_dir)); + .unwrap_or_else(|_| codex_services::export_storage::DEFAULT_EXPORTS_DIR.to_string()); + let export_storage = Arc::new(codex_services::ExportStorage::new(exports_dir)); // Initialize worker tracking variables let mut worker_handles = Vec::new(); @@ -455,7 +455,7 @@ pub async fn serve_command(config_path: PathBuf) -> anyhow::Result<()> { ); // Create application state for API - let refresh_token_service = Arc::new(crate::services::RefreshTokenService::new( + let refresh_token_service = Arc::new(codex_services::RefreshTokenService::new( db.sea_orm_connection().clone(), config.auth.refresh_token_expiry_days, )); @@ -485,7 +485,7 @@ pub async fn serve_command(config_path: PathBuf) -> anyhow::Result<()> { auth_tracking_service, pdf_page_cache, pdf_handle_cache, - inflight_thumbnails: Arc::new(crate::services::InflightThumbnailTracker::new()), + inflight_thumbnails: Arc::new(codex_services::InflightThumbnailTracker::new()), user_auth_cache: Arc::new(crate::api::extractors::auth::UserAuthCache::new()), rate_limiter_service, plugin_manager: plugin_manager.clone(), diff --git a/src/commands/worker.rs b/src/commands/worker.rs index 5decba22..da6d45c2 100644 --- a/src/commands/worker.rs +++ b/src/commands/worker.rs @@ -59,14 +59,14 @@ pub async fn worker_command(config_path: PathBuf) -> anyhow::Result<()> { info!("Event broadcaster initialized"); // Initialize thumbnail service - let thumbnail_service = Arc::new(crate::services::ThumbnailService::new(config.files.clone())); + let thumbnail_service = Arc::new(codex_services::ThumbnailService::new(config.files.clone())); info!( "Files service initialized (thumbnails: {}, uploads: {})", config.files.thumbnail_dir, config.files.uploads_dir ); // Initialize task metrics service - let task_metrics_service = Arc::new(crate::services::TaskMetricsService::new( + let task_metrics_service = Arc::new(codex_services::TaskMetricsService::new( db.sea_orm_connection().clone(), settings_service.clone(), )); @@ -79,7 +79,7 @@ pub async fn worker_command(config_path: PathBuf) -> anyhow::Result<()> { info!("Task metrics background jobs started"); // Initialize PDF page cache service - let pdf_page_cache = Arc::new(crate::services::PdfPageCache::new( + let pdf_page_cache = Arc::new(codex_services::PdfPageCache::new( &config.pdf.cache_dir, config.pdf.cache_rendered_pages, )); @@ -97,7 +97,7 @@ pub async fn worker_command(config_path: PathBuf) -> anyhow::Result<()> { // serve API requests, but the scanner still updates books and we want the // cache contract (open once) to hold across deployments that share state. let handle_cache_cfg = &config.pdf_handle_cache; - let pdf_handle_cache = Arc::new(crate::services::PdfHandleCache::new( + let pdf_handle_cache = Arc::new(codex_services::PdfHandleCache::new( handle_cache_cfg.capacity, std::time::Duration::from_secs(handle_cache_cfg.idle_ttl_minutes * 60), handle_cache_cfg.enabled, @@ -125,7 +125,7 @@ pub async fn worker_command(config_path: PathBuf) -> anyhow::Result<()> { // Initialize plugin metrics service for plugin operation metrics info!("Initializing plugin metrics service..."); - let plugin_metrics_service = Arc::new(crate::services::PluginMetricsService::new()); + let plugin_metrics_service = Arc::new(codex_services::PluginMetricsService::new()); // Initialize plugin manager for plugin auto-match tasks // @@ -135,7 +135,7 @@ pub async fn worker_command(config_path: PathBuf) -> anyhow::Result<()> { // See `codex_events::with_recording_broadcaster`. info!("Initializing plugin manager..."); let plugin_manager = Arc::new( - crate::services::plugin::PluginManager::with_defaults(Arc::new( + codex_services::plugin::PluginManager::with_defaults(Arc::new( db.sea_orm_connection().clone(), )) .with_metrics_service(plugin_metrics_service), @@ -153,11 +153,11 @@ pub async fn worker_command(config_path: PathBuf) -> anyhow::Result<()> { let exports_dir = settings_service .get_string( "exports.dir", - crate::services::export_storage::DEFAULT_EXPORTS_DIR, + codex_services::export_storage::DEFAULT_EXPORTS_DIR, ) .await - .unwrap_or_else(|_| crate::services::export_storage::DEFAULT_EXPORTS_DIR.to_string()); - let export_storage = Arc::new(crate::services::ExportStorage::new(exports_dir)); + .unwrap_or_else(|_| codex_services::export_storage::DEFAULT_EXPORTS_DIR.to_string()); + let export_storage = Arc::new(codex_services::ExportStorage::new(exports_dir)); // Spawn multiple workers for parallel task processing let (worker_handles, worker_shutdown_channels) = spawn_workers( diff --git a/src/lib.rs b/src/lib.rs index 2ebe8a04..4901f747 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -3,17 +3,17 @@ pub mod observability; pub mod scanner; pub mod scheduler; pub mod search; -pub mod services; pub mod tasks; pub mod web; // Re-exports of workspace-leaf crates so existing `codex::config::*`, // `codex::db::*`, `codex::events::*`, `codex::models::*`, `codex::parsers::*`, -// and `codex::utils::*` paths (used pervasively in integration tests) keep -// resolving without churn. +// `codex::services::*`, and `codex::utils::*` paths (used pervasively in +// integration tests) keep resolving without churn. pub use codex_config as config; pub use codex_db as db; pub use codex_events as events; pub use codex_models as models; pub use codex_parsers as parsers; +pub use codex_services as services; pub use codex_utils as utils; diff --git a/src/main.rs b/src/main.rs index 5eca9028..d7c871a6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,7 +4,6 @@ mod observability; mod scanner; mod scheduler; mod search; -mod services; mod tasks; mod web; diff --git a/src/observability/mod.rs b/src/observability/mod.rs index de0cc891..fb277ef3 100644 --- a/src/observability/mod.rs +++ b/src/observability/mod.rs @@ -28,10 +28,9 @@ pub use stub::{ObservabilityHandle, TraceContextFormat, init}; mod http; pub use http::install_http_layers; -#[cfg(feature = "observability")] -pub mod metrics; -#[cfg(not(feature = "observability"))] -#[path = "metrics_stub.rs"] -pub mod metrics; +// `metrics` lives in codex-services now (plugin/task lifecycle is a service +// concern). Re-exported here so existing call sites that say +// `observability::metrics::*` keep resolving. +pub use codex_services::metrics; pub mod inventory; diff --git a/src/scanner/analyzer_queue.rs b/src/scanner/analyzer_queue.rs index 940e0bd2..395dc31d 100644 --- a/src/scanner/analyzer_queue.rs +++ b/src/scanner/analyzer_queue.rs @@ -13,7 +13,6 @@ use crate::scanner::strategies::{ BookMetadata, BookNamingContext, NumberContext, NumberMetadata, create_book_strategy, create_number_strategy, }; -use crate::tasks::types::TaskType; use codex_db::entities::book_error::{BookError, BookErrorType}; use codex_db::entities::{book_metadata, books, pages}; use codex_db::repositories::{ @@ -21,6 +20,7 @@ use codex_db::repositories::{ LibraryRepository, PageRepository, SeriesMetadataRepository, SeriesRepository, TaskRepository, }; use codex_events::EventBroadcaster; +use codex_models::task::TaskType; use codex_models::{BookStrategy, CalibreStrategyConfig, NumberStrategy, SeriesStrategy}; use codex_parsers::opf; use codex_utils::normalize_for_search; diff --git a/src/scanner/library_scanner.rs b/src/scanner/library_scanner.rs index c5a94e89..3cc93513 100644 --- a/src/scanner/library_scanner.rs +++ b/src/scanner/library_scanner.rs @@ -14,11 +14,11 @@ use tracing::{debug, error, info, warn}; use uuid::Uuid; use walkdir::WalkDir; -use crate::tasks::types::TaskType; use codex_db::entities::{books, series}; use codex_db::repositories::{BookRepository, LibraryRepository, SeriesRepository, TaskRepository}; use codex_events::{EventBroadcaster, TaskProgressEvent}; use codex_models::SeriesStrategy; +use codex_models::task::TaskType; use super::strategies::{DetectedSeries, create_strategy}; use super::types::{ScanMode, ScanProgress, ScanResult, ScanStatus, ScannerConfig}; @@ -306,14 +306,14 @@ struct BookBatch { /// PDF handle cache to invalidate when book file content changes on disk. /// `BookRepository::update_batch` is silent (no per-book events), so the /// global event subscriber would miss these mutations: evict directly. - pdf_handle_cache: Option<Arc<crate::services::PdfHandleCache>>, + pdf_handle_cache: Option<Arc<codex_services::PdfHandleCache>>, } impl BookBatch { fn new( capacity: usize, force_analysis: bool, - pdf_handle_cache: Option<Arc<crate::services::PdfHandleCache>>, + pdf_handle_cache: Option<Arc<codex_services::PdfHandleCache>>, ) -> Self { Self { to_create: Vec::with_capacity(capacity), @@ -448,7 +448,7 @@ pub async fn scan_library( progress_tx: Option<mpsc::Sender<ScanProgress>>, event_broadcaster: Option<&Arc<EventBroadcaster>>, task_id: Option<Uuid>, - pdf_handle_cache: Option<Arc<crate::services::PdfHandleCache>>, + pdf_handle_cache: Option<Arc<codex_services::PdfHandleCache>>, ) -> Result<ScanResult> { let scan_start = Instant::now(); info!("Starting {} scan for library {}", mode, library_id); @@ -565,7 +565,7 @@ async fn scan_batched( progress_tx: Option<mpsc::Sender<ScanProgress>>, event_broadcaster: Option<&Arc<EventBroadcaster>>, task_id: Option<Uuid>, - pdf_handle_cache: Option<Arc<crate::services::PdfHandleCache>>, + pdf_handle_cache: Option<Arc<codex_services::PdfHandleCache>>, ) -> Result<ScanResult> { // Load scanner configuration from database settings let config = ScannerConfig::load(db).await; @@ -958,7 +958,7 @@ async fn process_series_batched( mode: ScanMode, config: &ScannerConfig, event_broadcaster: Option<&Arc<EventBroadcaster>>, - pdf_handle_cache: Option<Arc<crate::services::PdfHandleCache>>, + pdf_handle_cache: Option<Arc<codex_services::PdfHandleCache>>, ) -> Result<(SeriesProcessResult, bool)> { let mut result = SeriesProcessResult::new(); @@ -1263,11 +1263,11 @@ async fn find_or_create_series( fingerprint: Option<&str>, path: &str, all_series_paths: &HashSet<String>, - preprocessing_rules: &[crate::services::metadata::preprocessing::PreprocessingRule], + preprocessing_rules: &[codex_services::metadata::preprocessing::PreprocessingRule], event_broadcaster: Option<&Arc<EventBroadcaster>>, ) -> Result<series::Model> { - use crate::services::metadata::preprocessing::apply_rules; use codex_db::repositories::SeriesMetadataRepository; + use codex_services::metadata::preprocessing::apply_rules; debug!( "find_or_create_series: name='{}', path='{}', fingerprint={:?}", diff --git a/src/scheduler/mod.rs b/src/scheduler/mod.rs index 38e2dd9d..f6f7426d 100644 --- a/src/scheduler/mod.rs +++ b/src/scheduler/mod.rs @@ -8,11 +8,11 @@ use tracing::{debug, error, info, warn}; use uuid::Uuid; use crate::scanner::{ScanMode, ScanningConfig}; -use crate::services::library_jobs::{LibraryJobConfig, parse_job_config}; -use crate::services::settings::SettingsService; use crate::tasks::types::TaskType; use codex_db::entities::library_jobs; use codex_db::repositories::{LibraryJobRepository, LibraryRepository, TaskRepository}; +use codex_services::library_jobs::{LibraryJobConfig, parse_job_config}; +use codex_services::settings::SettingsService; use codex_utils::cron::{normalize_cron_expression, parse_timezone}; /// Generic scheduler for managing scheduled tasks (library scans, deduplication, etc.) @@ -743,7 +743,7 @@ impl Scheduler { } /// Adapter that lets the `services` layer drive a `Scheduler` through the -/// [`crate::services::scheduler_handle::SchedulerReconciler`] trait without +/// [`codex_services::scheduler_handle::SchedulerReconciler`] trait without /// holding the concrete type. The trait inverts the layer dependency so /// `services` can ask for a reconcile without importing `scheduler`. pub struct LockedSchedulerReconciler { @@ -756,7 +756,7 @@ impl LockedSchedulerReconciler { } } -impl crate::services::scheduler_handle::SchedulerReconciler for LockedSchedulerReconciler { +impl codex_services::scheduler_handle::SchedulerReconciler for LockedSchedulerReconciler { fn reconcile_release_sources(&self) -> futures::future::BoxFuture<'_, Result<()>> { Box::pin(async move { let mut guard = self.inner.lock().await; diff --git a/src/scheduler/release_sources.rs b/src/scheduler/release_sources.rs index 22a93e11..123afb88 100644 --- a/src/scheduler/release_sources.rs +++ b/src/scheduler/release_sources.rs @@ -23,10 +23,10 @@ use tokio_cron_scheduler::{Job, JobScheduler}; use tracing::{debug, error, info, warn}; use uuid::Uuid; -use crate::services::release::schedule::{read_default_cron_schedule, resolve_cron_schedule}; -use crate::services::settings::SettingsService; use crate::tasks::types::TaskType; use codex_db::repositories::{ReleaseSourceRepository, TaskRepository}; +use codex_services::release::schedule::{read_default_cron_schedule, resolve_cron_schedule}; +use codex_services::settings::SettingsService; use codex_utils::cron::normalize_cron_expression; /// Tracks scheduler-registered jobs per source row so we can reconcile. diff --git a/src/tasks/error.rs b/src/tasks/error.rs index a4379180..52e5d6a6 100644 --- a/src/tasks/error.rs +++ b/src/tasks/error.rs @@ -12,7 +12,7 @@ //! Rate-limited tasks use a separate counter to avoid exhausting retry attempts on //! expected throttling behavior. -use crate::services::plugin::PluginManagerError; +use codex_services::plugin::PluginManagerError; // Re-exported from `codex_models::task` so existing call sites work and the // canonical constants live in the shared `models` layer (avoids db -> tasks @@ -124,8 +124,8 @@ impl RateLimitedError for PluginManagerError { #[cfg(test)] mod tests { use super::*; - use crate::services::plugin::handle::PluginError; - use crate::services::plugin::rpc::RpcError; + use codex_services::plugin::handle::PluginError; + use codex_services::plugin::rpc::RpcError; use uuid::Uuid; /// Helper to create an RPC rate limit error wrapped in PluginManagerError diff --git a/src/tasks/handlers/backfill_tracking.rs b/src/tasks/handlers/backfill_tracking.rs index 625d992c..8839de94 100644 --- a/src/tasks/handlers/backfill_tracking.rs +++ b/src/tasks/handlers/backfill_tracking.rs @@ -17,12 +17,12 @@ use std::sync::Arc; use tracing::{info, warn}; use uuid::Uuid; -use crate::services::release::seed::{SeedReport, seed_tracking_for_series}; use crate::tasks::handlers::TaskHandler; use crate::tasks::types::TaskResult; use codex_db::entities::tasks; use codex_db::repositories::SeriesRepository; use codex_events::EventBroadcaster; +use codex_services::release::seed::{SeedReport, seed_tracking_for_series}; pub struct BackfillTrackingFromMetadataHandler; diff --git a/src/tasks/handlers/bulk_track_for_releases.rs b/src/tasks/handlers/bulk_track_for_releases.rs index 2d8eeef4..5072fb50 100644 --- a/src/tasks/handlers/bulk_track_for_releases.rs +++ b/src/tasks/handlers/bulk_track_for_releases.rs @@ -3,7 +3,7 @@ //! Drives the bulk-toggle work that used to happen synchronously inside the //! `POST /series/bulk/{track,untrack}-for-releases` HTTP request. Each //! series goes through the shared -//! [`crate::services::release::tracking_toggle`] helpers, which keep the +//! [`codex_services::release::tracking_toggle`] helpers, which keep the //! "track on -> seed first, then flip" / "track off -> flip only" ordering //! identical to the per-series PATCH path. //! @@ -17,13 +17,13 @@ use std::sync::Arc; use tracing::{info, warn}; use uuid::Uuid; -use crate::services::release::tracking_toggle::{ - ToggleOutcome, ToggleResult, track_one_series, untrack_one_series, -}; use crate::tasks::handlers::TaskHandler; use crate::tasks::types::TaskResult; use codex_db::entities::tasks; use codex_events::{EventBroadcaster, TaskProgressEvent}; +use codex_services::release::tracking_toggle::{ + ToggleOutcome, ToggleResult, track_one_series, untrack_one_series, +}; pub struct BulkTrackForReleasesHandler; diff --git a/src/tasks/handlers/cleanup_book_files.rs b/src/tasks/handlers/cleanup_book_files.rs index 2b1a63e7..f9f2a4c9 100644 --- a/src/tasks/handlers/cleanup_book_files.rs +++ b/src/tasks/handlers/cleanup_book_files.rs @@ -12,12 +12,12 @@ use std::path::PathBuf; use std::sync::Arc; use tracing::{debug, info, warn}; -use crate::services::{FileCleanupService, ThumbnailService}; use crate::tasks::handlers::TaskHandler; use crate::tasks::types::TaskResult; use codex_config::FilesConfig; use codex_db::entities::tasks; use codex_events::EventBroadcaster; +use codex_services::{FileCleanupService, ThumbnailService}; /// Handler for cleaning up book files after deletion pub struct CleanupBookFilesHandler { diff --git a/src/tasks/handlers/cleanup_orphaned_files.rs b/src/tasks/handlers/cleanup_orphaned_files.rs index 8f752fa7..56b8c1fa 100644 --- a/src/tasks/handlers/cleanup_orphaned_files.rs +++ b/src/tasks/handlers/cleanup_orphaned_files.rs @@ -10,13 +10,13 @@ use serde_json::json; use std::sync::Arc; use tracing::{debug, info}; -use crate::services::{CleanupStats, FileCleanupService, OrphanedFileType}; use crate::tasks::handlers::TaskHandler; use crate::tasks::types::TaskResult; use codex_config::FilesConfig; use codex_db::entities::tasks; use codex_db::repositories::{BookRepository, SeriesRepository}; use codex_events::EventBroadcaster; +use codex_services::{CleanupStats, FileCleanupService, OrphanedFileType}; /// Handler for cleaning up orphaned files pub struct CleanupOrphanedFilesHandler { diff --git a/src/tasks/handlers/cleanup_pdf_cache.rs b/src/tasks/handlers/cleanup_pdf_cache.rs index c8409f3b..d9fda503 100644 --- a/src/tasks/handlers/cleanup_pdf_cache.rs +++ b/src/tasks/handlers/cleanup_pdf_cache.rs @@ -9,11 +9,11 @@ use serde_json::json; use std::sync::Arc; use tracing::info; -use crate::services::{PdfPageCache, SettingsService}; use crate::tasks::handlers::TaskHandler; use crate::tasks::types::TaskResult; use codex_db::entities::tasks; use codex_events::EventBroadcaster; +use codex_services::{PdfPageCache, SettingsService}; /// Handler for cleaning up old PDF cache pages pub struct CleanupPdfCacheHandler { diff --git a/src/tasks/handlers/cleanup_plugin_data.rs b/src/tasks/handlers/cleanup_plugin_data.rs index 50e1ac10..e79b4b9b 100644 --- a/src/tasks/handlers/cleanup_plugin_data.rs +++ b/src/tasks/handlers/cleanup_plugin_data.rs @@ -11,12 +11,12 @@ use serde_json::json; use std::sync::Arc; use tracing::info; -use crate::services::user_plugin::OAuthStateManager; use crate::tasks::handlers::TaskHandler; use crate::tasks::types::TaskResult; use codex_db::entities::tasks; use codex_db::repositories::UserPluginDataRepository; use codex_events::EventBroadcaster; +use codex_services::user_plugin::OAuthStateManager; /// Handler for cleaning up expired plugin storage data and OAuth state #[derive(Default)] @@ -82,7 +82,7 @@ impl TaskHandler for CleanupPluginDataHandler { #[cfg(test)] mod tests { use super::*; - use crate::services::plugin::protocol::OAuthConfig; + use codex_services::plugin::protocol::OAuthConfig; use uuid::Uuid; #[test] diff --git a/src/tasks/handlers/cleanup_series_exports.rs b/src/tasks/handlers/cleanup_series_exports.rs index 4418933c..a33c90a9 100644 --- a/src/tasks/handlers/cleanup_series_exports.rs +++ b/src/tasks/handlers/cleanup_series_exports.rs @@ -11,13 +11,13 @@ use serde_json::json; use std::sync::Arc; use tracing::{info, warn}; -use crate::services::SettingsService; -use crate::services::export_storage::ExportStorage; use crate::tasks::handlers::TaskHandler; use crate::tasks::types::TaskResult; use codex_db::entities::tasks; use codex_db::repositories::SeriesExportRepository; use codex_events::{EventBroadcaster, TaskProgressEvent}; +use codex_services::SettingsService; +use codex_services::export_storage::ExportStorage; /// Default global storage cap: 2 GiB const DEFAULT_STORAGE_CAP_BYTES: u64 = 2 * 1024 * 1024 * 1024; diff --git a/src/tasks/handlers/cleanup_series_files.rs b/src/tasks/handlers/cleanup_series_files.rs index 2855f4bd..40b22f22 100644 --- a/src/tasks/handlers/cleanup_series_files.rs +++ b/src/tasks/handlers/cleanup_series_files.rs @@ -9,12 +9,12 @@ use serde_json::json; use std::sync::Arc; use tracing::{debug, info, warn}; -use crate::services::FileCleanupService; use crate::tasks::handlers::TaskHandler; use crate::tasks::types::TaskResult; use codex_config::FilesConfig; use codex_db::entities::tasks; use codex_events::EventBroadcaster; +use codex_services::FileCleanupService; /// Handler for cleaning up series files after deletion pub struct CleanupSeriesFilesHandler { diff --git a/src/tasks/handlers/export_series.rs b/src/tasks/handlers/export_series.rs index 43e44b83..39a1b487 100644 --- a/src/tasks/handlers/export_series.rs +++ b/src/tasks/handlers/export_series.rs @@ -13,16 +13,16 @@ use std::sync::Arc; use tracing::{error, info, warn}; use uuid::Uuid; -use crate::services::SettingsService; -use crate::services::book_export_collector::{self, BookExportField, BookExportRow}; -use crate::services::export_storage::ExportStorage; -use crate::services::series_export_collector::{self, ExportField, SeriesExportRow}; -use crate::services::series_export_writer; use crate::tasks::handlers::TaskHandler; use crate::tasks::types::TaskResult; use codex_db::entities::tasks; use codex_db::repositories::SeriesExportRepository; use codex_events::{EventBroadcaster, TaskProgressEvent}; +use codex_services::SettingsService; +use codex_services::book_export_collector::{self, BookExportField, BookExportRow}; +use codex_services::export_storage::ExportStorage; +use codex_services::series_export_collector::{self, ExportField, SeriesExportRow}; +use codex_services::series_export_writer; /// Default maximum number of completed exports kept per user. const DEFAULT_MAX_PER_USER: u64 = 10; diff --git a/src/tasks/handlers/generate_series_thumbnail.rs b/src/tasks/handlers/generate_series_thumbnail.rs index 9e1c7ea7..1e6479ec 100644 --- a/src/tasks/handlers/generate_series_thumbnail.rs +++ b/src/tasks/handlers/generate_series_thumbnail.rs @@ -8,12 +8,12 @@ use sea_orm::DatabaseConnection; use std::sync::Arc; use tracing::{debug, info, warn}; -use crate::services::ThumbnailService; use crate::tasks::handlers::TaskHandler; use crate::tasks::types::TaskResult; use codex_db::entities::tasks; use codex_db::repositories::{BookRepository, SeriesCoversRepository, SeriesRepository}; use codex_events::{EntityChangeEvent, EntityEvent, EntityType, EventBroadcaster}; +use codex_services::ThumbnailService; pub struct GenerateSeriesThumbnailHandler { thumbnail_service: Arc<ThumbnailService>, diff --git a/src/tasks/handlers/generate_series_thumbnails.rs b/src/tasks/handlers/generate_series_thumbnails.rs index 35568f9e..f0dd5783 100644 --- a/src/tasks/handlers/generate_series_thumbnails.rs +++ b/src/tasks/handlers/generate_series_thumbnails.rs @@ -9,12 +9,12 @@ use sea_orm::DatabaseConnection; use std::sync::Arc; use tracing::{debug, info, warn}; -use crate::services::ThumbnailService; use crate::tasks::handlers::TaskHandler; use crate::tasks::types::{TaskResult, TaskType}; use codex_db::entities::tasks; use codex_db::repositories::{SeriesRepository, TaskRepository}; use codex_events::{EventBroadcaster, TaskProgressEvent}; +use codex_services::ThumbnailService; pub struct GenerateSeriesThumbnailsHandler { thumbnail_service: Arc<ThumbnailService>, diff --git a/src/tasks/handlers/generate_thumbnail.rs b/src/tasks/handlers/generate_thumbnail.rs index 82745df2..ef1250a0 100644 --- a/src/tasks/handlers/generate_thumbnail.rs +++ b/src/tasks/handlers/generate_thumbnail.rs @@ -3,13 +3,13 @@ use sea_orm::DatabaseConnection; use std::sync::Arc; use tracing::{debug, error, info, warn}; -use crate::services::ThumbnailService; use crate::tasks::handlers::TaskHandler; use crate::tasks::types::{TaskResult, TaskType}; use codex_db::entities::book_error::{BookError, BookErrorType}; use codex_db::entities::tasks; use codex_db::repositories::{BookRepository, SeriesRepository, TaskRepository}; use codex_events::{EntityChangeEvent, EntityEvent, EntityType, EventBroadcaster}; +use codex_services::ThumbnailService; pub struct GenerateThumbnailHandler { thumbnail_service: Arc<ThumbnailService>, diff --git a/src/tasks/handlers/generate_thumbnails.rs b/src/tasks/handlers/generate_thumbnails.rs index 6eacdd28..a05a82eb 100644 --- a/src/tasks/handlers/generate_thumbnails.rs +++ b/src/tasks/handlers/generate_thumbnails.rs @@ -3,12 +3,12 @@ use sea_orm::DatabaseConnection; use std::sync::Arc; use tracing::{debug, info, warn}; -use crate::services::ThumbnailService; use crate::tasks::handlers::TaskHandler; use crate::tasks::types::{TaskResult, TaskType}; use codex_db::entities::tasks; use codex_db::repositories::{BookRepository, TaskRepository}; use codex_events::{EventBroadcaster, TaskProgressEvent}; +use codex_services::ThumbnailService; pub struct GenerateThumbnailsHandler { thumbnail_service: Arc<ThumbnailService>, diff --git a/src/tasks/handlers/plugin_auto_match.rs b/src/tasks/handlers/plugin_auto_match.rs index 7f4fd214..058d7be6 100644 --- a/src/tasks/handlers/plugin_auto_match.rs +++ b/src/tasks/handlers/plugin_auto_match.rs @@ -19,19 +19,6 @@ use std::sync::Arc; use tracing::{debug, error, info, warn}; use uuid::Uuid; -use crate::services::ThumbnailService; -use crate::services::metadata::preprocessing::{ - AutoMatchConditions, PreprocessingRule, SeriesContext, SeriesContextBuilder, apply_rules, - render_template, should_match, -}; -use crate::services::metadata::{ - ApplyOptions, BookApplyOptions, BookMetadataApplier, MetadataApplier, SkippedField, -}; -use crate::services::plugin::protocol::{ - BookSearchParams, MetadataGetParams, MetadataSearchParams, -}; -use crate::services::plugin::{PluginManager, PluginManagerError}; -use crate::services::settings::SettingsService; use crate::tasks::handlers::TaskHandler; use crate::tasks::types::TaskResult; use codex_db::entities::tasks; @@ -40,6 +27,17 @@ use codex_db::repositories::{ PluginsRepository, SeriesExternalIdRepository, SeriesMetadataRepository, SeriesRepository, }; use codex_events::{EntityChangeEvent, EntityEvent, EventBroadcaster, TaskProgressEvent}; +use codex_services::ThumbnailService; +use codex_services::metadata::preprocessing::{ + AutoMatchConditions, PreprocessingRule, SeriesContext, SeriesContextBuilder, apply_rules, + render_template, should_match, +}; +use codex_services::metadata::{ + ApplyOptions, BookApplyOptions, BookMetadataApplier, MetadataApplier, SkippedField, +}; +use codex_services::plugin::protocol::{BookSearchParams, MetadataGetParams, MetadataSearchParams}; +use codex_services::plugin::{PluginManager, PluginManagerError}; +use codex_services::settings::SettingsService; /// Settings key for the auto-match confidence threshold const SETTING_AUTO_MATCH_CONFIDENCE_THRESHOLD: &str = "plugins.auto_match_confidence_threshold"; @@ -1216,7 +1214,7 @@ mod tests { #[test] fn test_apply_preprocessing_rules() { - use crate::services::metadata::preprocessing::PreprocessingRule; + use codex_services::metadata::preprocessing::PreprocessingRule; // Test with empty rules let result = apply_preprocessing_rules("One Piece (Digital)", &[], &[]); @@ -1251,7 +1249,7 @@ mod tests { #[test] fn test_check_conditions() { - use crate::services::metadata::preprocessing::{ + use codex_services::metadata::preprocessing::{ AutoMatchConditions, ConditionMode, ConditionOperator, ConditionRule, MetadataContext, }; diff --git a/src/tasks/handlers/poll_release_source.rs b/src/tasks/handlers/poll_release_source.rs index f0595b6b..a6307596 100644 --- a/src/tasks/handlers/poll_release_source.rs +++ b/src/tasks/handlers/poll_release_source.rs @@ -33,13 +33,6 @@ use std::time::Duration; use tracing::{debug, error, info, warn}; use uuid::Uuid; -use crate::services::SettingsService; -use crate::services::plugin::PluginManager; -use crate::services::plugin::handle::PluginError; -use crate::services::plugin::protocol::{ReleasePollRequest, ReleasePollResponse, methods}; -use crate::services::release::auto_ignore::{OwnedReleaseKeys, should_auto_ignore}; -use crate::services::release::backoff::{HostBackoff, is_backoff_status}; -use crate::services::release::matcher::{evaluate, resolve_threshold}; use crate::tasks::handlers::TaskHandler; use crate::tasks::types::TaskResult; use codex_db::entities::release_ledger::state as ledger_state; @@ -50,6 +43,13 @@ use codex_db::repositories::{ SeriesRepository, SeriesTrackingRepository, }; use codex_events::{EntityChangeEvent, EventBroadcaster}; +use codex_services::SettingsService; +use codex_services::plugin::PluginManager; +use codex_services::plugin::handle::PluginError; +use codex_services::plugin::protocol::{ReleasePollRequest, ReleasePollResponse, methods}; +use codex_services::release::auto_ignore::{OwnedReleaseKeys, should_auto_ignore}; +use codex_services::release::backoff::{HostBackoff, is_backoff_status}; +use codex_services::release::matcher::{evaluate, resolve_threshold}; /// Default plugin task timeout in seconds (5 minutes — same as user_plugin_sync). const DEFAULT_TASK_TIMEOUT_SECS: u64 = 300; @@ -429,9 +429,11 @@ impl TaskHandler for PollReleaseSourceHandler { { cached.clone() } else { - let resolved = - lookup_series_title(db, outcome.row.series_id) - .await; + let resolved = codex_services::release::announce::lookup_series_title( + db, + outcome.row.series_id, + ) + .await; series_title_cache .insert(outcome.row.series_id, resolved.clone()); resolved @@ -649,24 +651,6 @@ pub(crate) fn emit_release_announced( )); } -/// Resolve the display title for a series, preferring `series_metadata.title` -/// and falling back to the directory-derived `series.name`. Returns an empty -/// string if the series row is missing (shouldn't happen for a valid ledger -/// insert, but we don't want a notification failure to surface as a panic). -pub(crate) async fn lookup_series_title(db: &DatabaseConnection, series_id: Uuid) -> String { - match SeriesRepository::get_with_metadata(db, series_id).await { - Ok(Some((series, metadata))) => metadata.map(|m| m.title).unwrap_or(series.name), - Ok(None) => String::new(), - Err(e) => { - warn!( - "Failed to look up title for series {} (release notification): {}", - series_id, e - ); - String::new() - } - } -} - /// Compute the initial ledger state for a candidate. Returns /// `Some("ignored")` when the user already owns this volume/chapter; /// `None` falls back to the repository's default (`announced`). @@ -677,8 +661,8 @@ async fn resolve_initial_state( db: &DatabaseConnection, owned_cache: &mut std::collections::HashMap<Uuid, OwnedReleaseKeys>, series_id: Uuid, - volumes: Option<&[crate::services::release::candidate::NumericSpan]>, - chapters: Option<&[crate::services::release::candidate::NumericSpan]>, + volumes: Option<&[codex_services::release::candidate::NumericSpan]>, + chapters: Option<&[codex_services::release::candidate::NumericSpan]>, ) -> Result<Option<String>> { let has_v = volumes.is_some_and(|s| !s.is_empty()); let has_c = chapters.is_some_and(|s| !s.is_empty()); diff --git a/src/tasks/handlers/refresh_library_metadata.rs b/src/tasks/handlers/refresh_library_metadata.rs index adcab6d2..a7a76b03 100644 --- a/src/tasks/handlers/refresh_library_metadata.rs +++ b/src/tasks/handlers/refresh_library_metadata.rs @@ -20,15 +20,6 @@ use std::sync::Arc; use std::time::Duration; use tracing::{debug, error, info, warn}; -use crate::services::ThumbnailService; -use crate::services::library_jobs::{LibraryJobConfig, RefreshScope, parse_job_config}; -use crate::services::metadata::refresh_planner::{ - PlanFailure, PlannedRefresh, RefreshPlan, RefreshPlanner, SkipReason, - fields_filter_from_job_config, -}; -use crate::services::metadata::{ApplyOptions, MatchingStrategy, MetadataApplier}; -use crate::services::plugin::PluginManager; -use crate::services::plugin::protocol::{MetadataGetParams, MetadataMatchParams}; use crate::tasks::handlers::TaskHandler; use crate::tasks::types::TaskResult; use codex_db::entities::tasks; @@ -37,6 +28,15 @@ use codex_db::repositories::{ SeriesExternalIdRepository, SeriesMetadataRepository, SeriesRepository, }; use codex_events::{EntityChangeEvent, EntityEvent, EventBroadcaster, TaskProgressEvent}; +use codex_services::ThumbnailService; +use codex_services::library_jobs::{LibraryJobConfig, RefreshScope, parse_job_config}; +use codex_services::metadata::refresh_planner::{ + PlanFailure, PlannedRefresh, RefreshPlan, RefreshPlanner, SkipReason, + fields_filter_from_job_config, +}; +use codex_services::metadata::{ApplyOptions, MatchingStrategy, MetadataApplier}; +use codex_services::plugin::PluginManager; +use codex_services::plugin::protocol::{MetadataGetParams, MetadataMatchParams}; /// Soft cap to keep one job's refresh from monopolizing the worker. const MAX_CONCURRENCY_HARD_CAP: usize = 16; @@ -533,11 +533,6 @@ async fn rematch_external_id( #[cfg(test)] mod tests { use super::*; - use crate::services::library_jobs::{ - LibraryJobConfig, MetadataRefreshJobConfig, RefreshScope, parse_job_config, - }; - use crate::services::plugin::PluginManager; - use crate::services::plugin::protocol::PluginScope; use crate::tasks::types::TaskType; use codex_db::ScanningStrategy; use codex_db::entities::plugins::PluginPermission; @@ -546,6 +541,11 @@ mod tests { SeriesRepository, TaskRepository, }; use codex_db::test_helpers::setup_test_db; + use codex_services::library_jobs::{ + LibraryJobConfig, MetadataRefreshJobConfig, RefreshScope, parse_job_config, + }; + use codex_services::plugin::PluginManager; + use codex_services::plugin::protocol::PluginScope; use std::env; use std::sync::Once; diff --git a/src/tasks/handlers/reprocess_series_titles.rs b/src/tasks/handlers/reprocess_series_titles.rs index a28ee3fd..ae7adc55 100644 --- a/src/tasks/handlers/reprocess_series_titles.rs +++ b/src/tasks/handlers/reprocess_series_titles.rs @@ -10,7 +10,6 @@ use std::sync::Arc; use tracing::{debug, info, warn}; use uuid::Uuid; -use crate::services::metadata::preprocessing::apply_rules; use crate::tasks::handlers::TaskHandler; use crate::tasks::types::{TaskResult, TaskType}; use codex_db::entities::{series_metadata, tasks}; @@ -18,6 +17,7 @@ use codex_db::repositories::{ LibraryRepository, SeriesMetadataRepository, SeriesRepository, TaskRepository, }; use codex_events::{EntityChangeEvent, EntityEvent, EventBroadcaster, TaskProgressEvent}; +use codex_services::metadata::preprocessing::apply_rules; // ============================================================================= // ReprocessSeriesTitle Handler (Single Series) diff --git a/src/tasks/handlers/scan_library.rs b/src/tasks/handlers/scan_library.rs index f17aa6c8..b43ab165 100644 --- a/src/tasks/handlers/scan_library.rs +++ b/src/tasks/handlers/scan_library.rs @@ -5,8 +5,6 @@ use std::sync::Arc; use tracing::{debug, error, info, warn}; use crate::scanner::{ScanMode, ScanningConfig, scan_library}; -use crate::services::plugin::protocol::PluginScope; -use crate::services::settings::SettingsService; use crate::tasks::handlers::TaskHandler; use crate::tasks::types::{TaskResult, TaskType}; use codex_db::entities::tasks; @@ -14,6 +12,8 @@ use codex_db::repositories::{ BookRepository, LibraryRepository, PluginsRepository, SeriesRepository, TaskRepository, }; use codex_events::EventBroadcaster; +use codex_services::plugin::protocol::PluginScope; +use codex_services::settings::SettingsService; /// Settings key for enabling post-scan auto-match const SETTING_POST_SCAN_AUTO_MATCH_ENABLED: &str = "plugins.post_scan_auto_match_enabled"; @@ -22,7 +22,7 @@ const DEFAULT_POST_SCAN_AUTO_MATCH_ENABLED: bool = false; pub struct ScanLibraryHandler { settings_service: Option<Arc<SettingsService>>, - pdf_handle_cache: Option<Arc<crate::services::PdfHandleCache>>, + pdf_handle_cache: Option<Arc<codex_services::PdfHandleCache>>, } impl Default for ScanLibraryHandler { @@ -47,7 +47,7 @@ impl ScanLibraryHandler { /// Wire the PDF handle cache so the scanner can invalidate cached open /// `PdfDocument` handles when book files change on disk. - pub fn with_pdf_handle_cache(mut self, cache: Arc<crate::services::PdfHandleCache>) -> Self { + pub fn with_pdf_handle_cache(mut self, cache: Arc<codex_services::PdfHandleCache>) -> Self { self.pdf_handle_cache = Some(cache); self } diff --git a/src/tasks/handlers/user_plugin_recommendation_dismiss.rs b/src/tasks/handlers/user_plugin_recommendation_dismiss.rs index d7a979d8..24ea5c18 100644 --- a/src/tasks/handlers/user_plugin_recommendation_dismiss.rs +++ b/src/tasks/handlers/user_plugin_recommendation_dismiss.rs @@ -11,16 +11,16 @@ use std::time::Duration; use tracing::{debug, info, warn}; use uuid::Uuid; -use crate::services::SettingsService; -use crate::services::plugin::PluginManager; -use crate::services::plugin::protocol::methods; -use crate::services::plugin::recommendations::{ - DismissReason, RecommendationDismissRequest, RecommendationDismissResponse, -}; use crate::tasks::handlers::TaskHandler; use crate::tasks::types::TaskResult; use codex_db::entities::tasks; use codex_events::EventBroadcaster; +use codex_services::SettingsService; +use codex_services::plugin::PluginManager; +use codex_services::plugin::protocol::methods; +use codex_services::plugin::recommendations::{ + DismissReason, RecommendationDismissRequest, RecommendationDismissResponse, +}; /// Default plugin task timeout in seconds (5 minutes) const DEFAULT_TASK_TIMEOUT_SECS: u64 = 300; diff --git a/src/tasks/handlers/user_plugin_recommendations.rs b/src/tasks/handlers/user_plugin_recommendations.rs index 7548c4b9..ba52dca0 100644 --- a/src/tasks/handlers/user_plugin_recommendations.rs +++ b/src/tasks/handlers/user_plugin_recommendations.rs @@ -14,20 +14,20 @@ use std::time::Duration; use tracing::{debug, info, warn}; use uuid::Uuid; -use crate::services::SettingsService; -use crate::services::plugin::PluginManager; -use crate::services::plugin::library::build_user_library; -use crate::services::plugin::protocol::{ - PluginManifest, UserLibraryEntry, UserReadingStatus, methods, -}; -use crate::services::plugin::recommendations::{ - RecommendationClearResponse, RecommendationRequest, RecommendationResponse, -}; use crate::tasks::handlers::TaskHandler; use crate::tasks::types::TaskResult; use codex_db::entities::tasks; use codex_db::repositories::{PluginsRepository, UserPluginDataRepository, UserPluginsRepository}; use codex_events::EventBroadcaster; +use codex_services::SettingsService; +use codex_services::plugin::PluginManager; +use codex_services::plugin::library::build_user_library; +use codex_services::plugin::protocol::{ + PluginManifest, UserLibraryEntry, UserReadingStatus, methods, +}; +use codex_services::plugin::recommendations::{ + RecommendationClearResponse, RecommendationRequest, RecommendationResponse, +}; /// Default plugin task timeout in seconds (5 minutes) const DEFAULT_TASK_TIMEOUT_SECS: u64 = 300; diff --git a/src/tasks/handlers/user_plugin_sync/mod.rs b/src/tasks/handlers/user_plugin_sync/mod.rs index b0e11ed3..9da22830 100644 --- a/src/tasks/handlers/user_plugin_sync/mod.rs +++ b/src/tasks/handlers/user_plugin_sync/mod.rs @@ -25,17 +25,17 @@ use std::time::Duration; use tracing::{debug, error, info, warn}; use uuid::Uuid; -use crate::services::SettingsService; -use crate::services::plugin::PluginManager; -use crate::services::plugin::protocol::methods; -use crate::services::plugin::sync::{ - ExternalUserInfo, SyncPullRequest, SyncPullResponse, SyncPushRequest, SyncPushResponse, -}; use crate::tasks::handlers::TaskHandler; use crate::tasks::types::TaskResult; use codex_db::entities::tasks; use codex_db::repositories::{UserPluginDataRepository, UserPluginsRepository}; use codex_events::{EventBroadcaster, TaskProgressEvent}; +use codex_services::SettingsService; +use codex_services::plugin::PluginManager; +use codex_services::plugin::protocol::methods; +use codex_services::plugin::sync::{ + ExternalUserInfo, SyncPullRequest, SyncPullResponse, SyncPushRequest, SyncPushResponse, +}; pub(crate) use settings::CodexSyncSettings; @@ -201,10 +201,10 @@ impl TaskHandler for UserPluginSyncHandler { Ok(result) => result, Err(e) => { let reason = match &e { - crate::services::plugin::PluginManagerError::UserPluginNotFound { + codex_services::plugin::PluginManagerError::UserPluginNotFound { .. } => "user_plugin_not_found", - crate::services::plugin::PluginManagerError::PluginNotEnabled(_) => { + codex_services::plugin::PluginManagerError::PluginNotEnabled(_) => { "plugin_not_enabled" } _ => "plugin_start_failed", diff --git a/src/tasks/handlers/user_plugin_sync/pull.rs b/src/tasks/handlers/user_plugin_sync/pull.rs index 4a563509..e3826010 100644 --- a/src/tasks/handlers/user_plugin_sync/pull.rs +++ b/src/tasks/handlers/user_plugin_sync/pull.rs @@ -6,10 +6,10 @@ use std::collections::HashMap; use tracing::{debug, warn}; use uuid::Uuid; -use crate::services::plugin::sync::{SyncEntry, SyncReadingStatus}; use codex_db::repositories::{ BookRepository, ReadProgressRepository, SeriesExternalIdRepository, UserSeriesRatingRepository, }; +use codex_services::plugin::sync::{SyncEntry, SyncReadingStatus}; /// Match pulled sync entries to Codex series using external IDs and apply /// reading progress. diff --git a/src/tasks/handlers/user_plugin_sync/push.rs b/src/tasks/handlers/user_plugin_sync/push.rs index eebe1be9..71d8f7f2 100644 --- a/src/tasks/handlers/user_plugin_sync/push.rs +++ b/src/tasks/handlers/user_plugin_sync/push.rs @@ -6,11 +6,11 @@ use std::collections::{HashMap, HashSet}; use tracing::{debug, warn}; use uuid::Uuid; -use crate::services::plugin::sync::{SyncEntry, SyncProgress, SyncReadingStatus}; use codex_db::repositories::{ BookRepository, ReadProgressRepository, SeriesExternalIdRepository, SeriesMetadataRepository, UserSeriesRatingRepository, }; +use codex_services::plugin::sync::{SyncEntry, SyncProgress, SyncReadingStatus}; use super::settings::CodexSyncSettings; diff --git a/src/tasks/handlers/user_plugin_sync/tests.rs b/src/tasks/handlers/user_plugin_sync/tests.rs index 79eb6dab..340279b5 100644 --- a/src/tasks/handlers/user_plugin_sync/tests.rs +++ b/src/tasks/handlers/user_plugin_sync/tests.rs @@ -1,5 +1,4 @@ use super::*; -use crate::services::plugin::sync::{SyncEntry, SyncProgress, SyncReadingStatus}; use chrono::Utc; use codex_db::ScanningStrategy; use codex_db::entities::{books, users}; @@ -8,6 +7,7 @@ use codex_db::repositories::{ SeriesMetadataRepository, SeriesRepository, UserRepository, UserSeriesRatingRepository, }; use codex_db::test_helpers::create_test_db; +use codex_services::plugin::sync::{SyncEntry, SyncProgress, SyncReadingStatus}; /// Helper to create a test user in the database async fn create_test_user(db: &sea_orm::DatabaseConnection) -> users::Model { diff --git a/src/tasks/worker.rs b/src/tasks/worker.rs index bdd54a8a..794a9b41 100644 --- a/src/tasks/worker.rs +++ b/src/tasks/worker.rs @@ -16,11 +16,6 @@ use tokio::time::sleep; use tracing::{debug, error, info, warn}; use uuid::Uuid; -use crate::services::PdfPageCache; -use crate::services::export_storage::ExportStorage; -use crate::services::plugin::PluginManager; -use crate::services::user_plugin::OAuthStateManager; -use crate::services::{SettingsService, TaskMetricsService, ThumbnailService}; use crate::tasks::error::check_rate_limited; use crate::tasks::handlers::{ AnalyzeBookHandler, AnalyzeSeriesHandler, BackfillTrackingFromMetadataHandler, @@ -38,6 +33,11 @@ use crate::tasks::handlers::{ use codex_config::FilesConfig; use codex_db::repositories::TaskRepository; use codex_events::{EventBroadcaster, RecordedEvent, TaskProgressEvent}; +use codex_services::PdfPageCache; +use codex_services::export_storage::ExportStorage; +use codex_services::plugin::PluginManager; +use codex_services::user_plugin::OAuthStateManager; +use codex_services::{SettingsService, TaskMetricsService, ThumbnailService}; /// RAII guard that increments the OTel in-flight task gauge on creation and /// decrements it on drop. Used by `process_next_task` to track currently- @@ -68,11 +68,11 @@ pub struct TaskWorker { thumbnail_service: Option<Arc<ThumbnailService>>, task_metrics_service: Option<Arc<TaskMetricsService>>, plugin_manager: Option<Arc<PluginManager>>, - pdf_handle_cache: Option<Arc<crate::services::PdfHandleCache>>, + pdf_handle_cache: Option<Arc<codex_services::PdfHandleCache>>, /// Shared per-host backoff state used by the `PollReleaseSourceHandler`. /// Exposed via [`Self::release_backoff`] so the scheduler can read the /// same multipliers when picking next-poll intervals. - release_backoff: crate::services::release::backoff::HostBackoff, + release_backoff: codex_services::release::backoff::HostBackoff, shutdown_tx: Option<broadcast::Sender<()>>, } @@ -158,7 +158,7 @@ impl TaskWorker { task_metrics_service: None, plugin_manager: None, pdf_handle_cache: None, - release_backoff: crate::services::release::backoff::HostBackoff::new(), + release_backoff: codex_services::release::backoff::HostBackoff::new(), shutdown_tx: None, } } @@ -166,7 +166,7 @@ impl TaskWorker { /// Shared per-host backoff used by `PollReleaseSourceHandler`. The /// scheduler reads this when computing the effective interval for the /// next poll. - pub fn release_backoff(&self) -> crate::services::release::backoff::HostBackoff { + pub fn release_backoff(&self) -> codex_services::release::backoff::HostBackoff { self.release_backoff.clone() } @@ -221,7 +221,7 @@ impl TaskWorker { /// Set the PDF handle cache so the scanner can invalidate cached open /// `PdfDocument` handles when book files change during a scan. - pub fn with_pdf_handle_cache(mut self, cache: Arc<crate::services::PdfHandleCache>) -> Self { + pub fn with_pdf_handle_cache(mut self, cache: Arc<codex_services::PdfHandleCache>) -> Self { self.pdf_handle_cache = Some(cache); self.register_scan_library_handler(); self From a768481abd564bee350eac027fd5eca25fc63734 Mon Sep 17 00:00:00 2001 From: Sylvain Cau <ashdevfr@gmail.com> Date: Sat, 23 May 2026 19:07:07 -0700 Subject: [PATCH 06/14] refactor(workspace): extract codex-search crate Moves the in-memory fuzzy search index (nucleo-matcher backed) out of the root crate into a new sibling workspace member. codex-search has no peer deps among the business-layer crates; it sits as a pure leaf alongside codex-services. Its only inputs are codex-db (entity reads for the index builder) and codex-events (the subscriber that patches the index on series/book changes). codex-utils is pulled in for the normalize_for_search helper. Root crate keeps `pub use codex_search as search` so callers using codex::search::* continue to resolve. Sed-swept crate::search::* sites in the api handlers to codex_search::*. --- Cargo.lock | 21 ++++++++++ Cargo.toml | 3 ++ crates/codex-search/Cargo.toml | 38 +++++++++++++++++++ .../codex-search/src}/builder.rs | 0 .../codex-search/src}/index.rs | 0 .../mod.rs => crates/codex-search/src/lib.rs | 0 .../codex-search/src}/listener.rs | 2 +- src/api/extractors/auth.rs | 2 +- src/commands/serve.rs | 6 +-- src/lib.rs | 2 +- src/main.rs | 1 - 11 files changed, 68 insertions(+), 7 deletions(-) create mode 100644 crates/codex-search/Cargo.toml rename {src/search => crates/codex-search/src}/builder.rs (100%) rename {src/search => crates/codex-search/src}/index.rs (100%) rename src/search/mod.rs => crates/codex-search/src/lib.rs (100%) rename {src/search => crates/codex-search/src}/listener.rs (99%) diff --git a/Cargo.lock b/Cargo.lock index cfb36ffa..2a31d9a5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -826,6 +826,7 @@ dependencies = [ "codex-events", "codex-models", "codex-parsers", + "codex-search", "codex-services", "codex-utils", "cron", @@ -980,6 +981,26 @@ dependencies = [ "zip", ] +[[package]] +name = "codex-search" +version = "0.0.0" +dependencies = [ + "anyhow", + "chrono", + "codex-db", + "codex-events", + "codex-utils", + "nucleo-matcher", + "parking_lot", + "sea-orm", + "serde_json", + "tempfile", + "tokio", + "tokio-util", + "tracing", + "uuid", +] + [[package]] name = "codex-services" version = "0.0.0" diff --git a/Cargo.toml b/Cargo.toml index b78ce9a4..dcc165a7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -39,6 +39,7 @@ members = [ "crates/codex-parsers", "crates/codex-db", "crates/codex-services", + "crates/codex-search", ] # Shared dependencies inherited by workspace members. Only deps that are @@ -66,6 +67,7 @@ codex-db = { path = "crates/codex-db" } codex-events = { path = "crates/codex-events" } codex-models = { path = "crates/codex-models" } codex-parsers = { path = "crates/codex-parsers", default-features = false } +codex-search = { path = "crates/codex-search" } codex-services = { path = "crates/codex-services" } codex-utils = { path = "crates/codex-utils" } @@ -149,6 +151,7 @@ codex-db = { workspace = true } codex-events = { workspace = true } codex-models = { workspace = true } codex-parsers = { workspace = true } +codex-search = { workspace = true } codex-services = { workspace = true } codex-utils = { workspace = true } tokio = { workspace = true } diff --git a/crates/codex-search/Cargo.toml b/crates/codex-search/Cargo.toml new file mode 100644 index 00000000..5da12cec --- /dev/null +++ b/crates/codex-search/Cargo.toml @@ -0,0 +1,38 @@ +[package] +name = "codex-search" +version = "0.0.0" +edition = "2024" +publish = false + +[lib] +name = "codex_search" +path = "src/lib.rs" + +[dependencies] +anyhow = { workspace = true } +chrono = { workspace = true } +tokio = { workspace = true } +tracing = { workspace = true } +uuid = { workspace = true } + +codex-db = { workspace = true } +codex-events = { workspace = true } +codex-utils = { workspace = true } + +# In-memory fuzzy index +nucleo-matcher = "0.3" +parking_lot = "0.12" +sea-orm = { version = "1.1", features = [ + "sqlx-postgres", + "sqlx-sqlite", + "runtime-tokio-rustls", + "macros", + "with-chrono", + "with-uuid", +] } +serde_json = "1.0" +tokio-util = { version = "0.7", features = ["io"] } + +[dev-dependencies] +tempfile = { workspace = true } +codex-db = { workspace = true, features = ["test-utils"] } diff --git a/src/search/builder.rs b/crates/codex-search/src/builder.rs similarity index 100% rename from src/search/builder.rs rename to crates/codex-search/src/builder.rs diff --git a/src/search/index.rs b/crates/codex-search/src/index.rs similarity index 100% rename from src/search/index.rs rename to crates/codex-search/src/index.rs diff --git a/src/search/mod.rs b/crates/codex-search/src/lib.rs similarity index 100% rename from src/search/mod.rs rename to crates/codex-search/src/lib.rs diff --git a/src/search/listener.rs b/crates/codex-search/src/listener.rs similarity index 99% rename from src/search/listener.rs rename to crates/codex-search/src/listener.rs index 438e57b9..b4e58083 100644 --- a/src/search/listener.rs +++ b/crates/codex-search/src/listener.rs @@ -213,7 +213,7 @@ async fn upsert_book(index: &FuzzyIndex, db: &DatabaseConnection, book_id: uuid: #[cfg(test)] mod tests { use super::*; - use crate::search::builder::build_from_db; + use crate::builder::build_from_db; use chrono::Utc; use codex_db::ScanningStrategy; use codex_db::entities::books; diff --git a/src/api/extractors/auth.rs b/src/api/extractors/auth.rs index 75731e80..b843da9c 100644 --- a/src/api/extractors/auth.rs +++ b/src/api/extractors/auth.rs @@ -245,7 +245,7 @@ pub struct AppState { /// Phase 2. Queries are gated by the `search.fuzzy.enabled` setting in /// Phase 3 — the handler falls back to the existing LIKE search when off. #[allow(dead_code)] - pub fuzzy_index: Arc<crate::search::FuzzyIndex>, + pub fuzzy_index: Arc<codex_search::FuzzyIndex>, } // Legacy alias for backwards compatibility during transition diff --git a/src/commands/serve.rs b/src/commands/serve.rs index 096f92c3..179031bc 100644 --- a/src/commands/serve.rs +++ b/src/commands/serve.rs @@ -432,14 +432,14 @@ pub async fn serve_command(config_path: PathBuf) -> anyhow::Result<()> { // reads) we fall back to an empty index and continue starting up; queries // will simply return no results until the event listener catches up. info!("Building in-memory fuzzy search index..."); - let fuzzy_index = match crate::search::builder::build_from_db(db.sea_orm_connection()).await { + let fuzzy_index = match codex_search::builder::build_from_db(db.sea_orm_connection()).await { Ok(idx) => Arc::new(idx), Err(err) => { tracing::warn!( "Failed to build fuzzy search index at startup: {err:#}. \ Continuing with an empty index; results will be incomplete until rebuild." ); - Arc::new(crate::search::FuzzyIndex::empty()) + Arc::new(codex_search::FuzzyIndex::empty()) } }; @@ -447,7 +447,7 @@ pub async fn serve_command(config_path: PathBuf) -> anyhow::Result<()> { // they happen. Lifetime is tied to `background_task_cancel`; on shutdown // either the cancel token fires or the broadcaster's shutdown signal // wakes the recv and the listener exits. - let fuzzy_listener_handle = crate::search::spawn_listener( + let fuzzy_listener_handle = codex_search::spawn_listener( fuzzy_index.clone(), event_broadcaster.clone(), db.sea_orm_connection().clone(), diff --git a/src/lib.rs b/src/lib.rs index 4901f747..9d1b827a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2,7 +2,6 @@ pub mod api; pub mod observability; pub mod scanner; pub mod scheduler; -pub mod search; pub mod tasks; pub mod web; @@ -15,5 +14,6 @@ pub use codex_db as db; pub use codex_events as events; pub use codex_models as models; pub use codex_parsers as parsers; +pub use codex_search as search; pub use codex_services as services; pub use codex_utils as utils; diff --git a/src/main.rs b/src/main.rs index d7c871a6..8b14ba70 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,7 +3,6 @@ mod commands; mod observability; mod scanner; mod scheduler; -mod search; mod tasks; mod web; From 0712a46a24534f91aa03298817141bb591c70d79 Mon Sep 17 00:00:00 2001 From: Sylvain Cau <ashdevfr@gmail.com> Date: Sat, 23 May 2026 19:25:47 -0700 Subject: [PATCH 07/14] refactor(workspace): extract codex-scanner crate Moves the library scanner (directory traversal, format detection, strategy resolution, book/series/page repository writes) out of the root crate into a new sibling workspace member. Depends on codex-db, codex-events, codex-models, codex-parsers, codex-services, codex-utils. The services dep is real: the scanner reaches for PdfHandleCache and the metadata preprocessing pipeline during analysis. The cyclic edge from scanner to tasks::types::TaskType was already retired before the services extraction by re-pointing the import at codex_models::task::TaskType, where TaskType has lived since the earlier layering cleanup. Owns its own rar feature that forwards to codex-parsers/rar so CBR files participate in the scan; the root rar feature now forwards to both codex-parsers and codex-scanner. Root crate keeps `pub use codex_scanner as scanner` so callers via codex::scanner::* continue to resolve. --- Cargo.lock | 29 +++++++++++ Cargo.toml | 5 +- crates/codex-scanner/Cargo.toml | 50 +++++++++++++++++++ .../codex-scanner/src}/analyzer.rs | 2 +- .../codex-scanner/src}/analyzer_queue.rs | 4 +- .../codex-scanner/src}/detector.rs | 0 .../mod.rs => crates/codex-scanner/src/lib.rs | 0 .../codex-scanner/src}/library_scanner.rs | 0 .../src}/strategies/book/custom.rs | 0 .../src}/strategies/book/filename.rs | 0 .../src}/strategies/book/metadata_first.rs | 0 .../codex-scanner/src}/strategies/book/mod.rs | 0 .../src}/strategies/book/series_name.rs | 0 .../src}/strategies/book/smart.rs | 0 .../codex-scanner/src}/strategies/common.rs | 0 .../codex-scanner/src}/strategies/mod.rs | 0 .../src}/strategies/number/file_order.rs | 0 .../src}/strategies/number/filename.rs | 0 .../src}/strategies/number/metadata.rs | 0 .../src}/strategies/number/mod.rs | 0 .../src}/strategies/number/smart.rs | 0 .../src}/strategies/series/calibre.rs | 0 .../src}/strategies/series/custom.rs | 0 .../src}/strategies/series/flat.rs | 0 .../src}/strategies/series/mod.rs | 0 .../strategies/series/publisher_hierarchy.rs | 0 .../src}/strategies/series/series_volume.rs | 0 .../series/series_volume_chapter.rs | 0 .../codex-scanner/src}/types.rs | 0 src/api/routes/v1/dto/scan.rs | 6 +-- src/api/routes/v1/handlers/libraries.rs | 2 +- src/api/routes/v1/handlers/scan.rs | 2 +- src/commands/scan.rs | 2 +- src/lib.rs | 2 +- src/main.rs | 1 - src/scheduler/mod.rs | 2 +- src/tasks/handlers/analyze_book.rs | 2 +- src/tasks/handlers/renumber_series.rs | 2 +- src/tasks/handlers/scan_library.rs | 2 +- 39 files changed, 97 insertions(+), 16 deletions(-) create mode 100644 crates/codex-scanner/Cargo.toml rename {src/scanner => crates/codex-scanner/src}/analyzer.rs (98%) rename {src/scanner => crates/codex-scanner/src}/analyzer_queue.rs (99%) rename {src/scanner => crates/codex-scanner/src}/detector.rs (100%) rename src/scanner/mod.rs => crates/codex-scanner/src/lib.rs (100%) rename {src/scanner => crates/codex-scanner/src}/library_scanner.rs (100%) rename {src/scanner => crates/codex-scanner/src}/strategies/book/custom.rs (100%) rename {src/scanner => crates/codex-scanner/src}/strategies/book/filename.rs (100%) rename {src/scanner => crates/codex-scanner/src}/strategies/book/metadata_first.rs (100%) rename {src/scanner => crates/codex-scanner/src}/strategies/book/mod.rs (100%) rename {src/scanner => crates/codex-scanner/src}/strategies/book/series_name.rs (100%) rename {src/scanner => crates/codex-scanner/src}/strategies/book/smart.rs (100%) rename {src/scanner => crates/codex-scanner/src}/strategies/common.rs (100%) rename {src/scanner => crates/codex-scanner/src}/strategies/mod.rs (100%) rename {src/scanner => crates/codex-scanner/src}/strategies/number/file_order.rs (100%) rename {src/scanner => crates/codex-scanner/src}/strategies/number/filename.rs (100%) rename {src/scanner => crates/codex-scanner/src}/strategies/number/metadata.rs (100%) rename {src/scanner => crates/codex-scanner/src}/strategies/number/mod.rs (100%) rename {src/scanner => crates/codex-scanner/src}/strategies/number/smart.rs (100%) rename {src/scanner => crates/codex-scanner/src}/strategies/series/calibre.rs (100%) rename {src/scanner => crates/codex-scanner/src}/strategies/series/custom.rs (100%) rename {src/scanner => crates/codex-scanner/src}/strategies/series/flat.rs (100%) rename {src/scanner => crates/codex-scanner/src}/strategies/series/mod.rs (100%) rename {src/scanner => crates/codex-scanner/src}/strategies/series/publisher_hierarchy.rs (100%) rename {src/scanner => crates/codex-scanner/src}/strategies/series/series_volume.rs (100%) rename {src/scanner => crates/codex-scanner/src}/strategies/series/series_volume_chapter.rs (100%) rename {src/scanner => crates/codex-scanner/src}/types.rs (100%) diff --git a/Cargo.lock b/Cargo.lock index 2a31d9a5..5f2e5db3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -826,6 +826,7 @@ dependencies = [ "codex-events", "codex-models", "codex-parsers", + "codex-scanner", "codex-search", "codex-services", "codex-utils", @@ -981,6 +982,34 @@ dependencies = [ "zip", ] +[[package]] +name = "codex-scanner" +version = "0.0.0" +dependencies = [ + "anyhow", + "chrono", + "codex-db", + "codex-events", + "codex-models", + "codex-parsers", + "codex-services", + "codex-utils", + "futures", + "globset", + "lazy_static", + "regex", + "sea-orm", + "serde", + "serde_json", + "sha2", + "tempfile", + "tokio", + "tracing", + "uuid", + "walkdir", + "zip", +] + [[package]] name = "codex-search" version = "0.0.0" diff --git a/Cargo.toml b/Cargo.toml index dcc165a7..46342bf0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,7 +14,7 @@ path = "src/main.rs" [features] default = ["rar", "observability"] -rar = ["codex-parsers/rar", "codex-services/rar"] +rar = ["codex-parsers/rar", "codex-scanner/rar", "codex-services/rar"] embed-frontend = [] observability = [ "dep:opentelemetry", @@ -40,6 +40,7 @@ members = [ "crates/codex-db", "crates/codex-services", "crates/codex-search", + "crates/codex-scanner", ] # Shared dependencies inherited by workspace members. Only deps that are @@ -67,6 +68,7 @@ codex-db = { path = "crates/codex-db" } codex-events = { path = "crates/codex-events" } codex-models = { path = "crates/codex-models" } codex-parsers = { path = "crates/codex-parsers", default-features = false } +codex-scanner = { path = "crates/codex-scanner", default-features = false } codex-search = { path = "crates/codex-search" } codex-services = { path = "crates/codex-services" } codex-utils = { path = "crates/codex-utils" } @@ -151,6 +153,7 @@ codex-db = { workspace = true } codex-events = { workspace = true } codex-models = { workspace = true } codex-parsers = { workspace = true } +codex-scanner = { workspace = true } codex-search = { workspace = true } codex-services = { workspace = true } codex-utils = { workspace = true } diff --git a/crates/codex-scanner/Cargo.toml b/crates/codex-scanner/Cargo.toml new file mode 100644 index 00000000..65077e57 --- /dev/null +++ b/crates/codex-scanner/Cargo.toml @@ -0,0 +1,50 @@ +[package] +name = "codex-scanner" +version = "0.0.0" +edition = "2024" +publish = false + +[lib] +name = "codex_scanner" +path = "src/lib.rs" + +[features] +default = [] +# Forwards to codex-parsers/rar so CBR files participate in the scan. +rar = ["codex-parsers/rar"] + +[dependencies] +anyhow = { workspace = true } +chrono = { workspace = true } +serde = { workspace = true } +tokio = { workspace = true } +tracing = { workspace = true } +uuid = { workspace = true } + +codex-db = { workspace = true } +codex-events = { workspace = true } +codex-models = { workspace = true } +codex-parsers = { workspace = true } +codex-services = { workspace = true } +codex-utils = { workspace = true } + +futures = "0.3" +globset = "0.4" +lazy_static = "1.4" +regex = "1.10" +serde_json = "1.0" +zip = "8.1" +sea-orm = { version = "1.1", features = [ + "sqlx-postgres", + "sqlx-sqlite", + "runtime-tokio-rustls", + "macros", + "with-chrono", + "with-uuid", +] } +sha2 = "0.10" +walkdir = "2.5" + +[dev-dependencies] +tempfile = { workspace = true } +codex-db = { workspace = true, features = ["test-utils"] } diff --git a/src/scanner/analyzer.rs b/crates/codex-scanner/src/analyzer.rs similarity index 98% rename from src/scanner/analyzer.rs rename to crates/codex-scanner/src/analyzer.rs index cfd723ff..3b8bd60e 100644 --- a/src/scanner/analyzer.rs +++ b/crates/codex-scanner/src/analyzer.rs @@ -1,4 +1,4 @@ -use crate::scanner::detect_format; +use crate::detect_format; use codex_parsers::BookMetadata; #[cfg(feature = "rar")] use codex_parsers::cbr::CbrParser; diff --git a/src/scanner/analyzer_queue.rs b/crates/codex-scanner/src/analyzer_queue.rs similarity index 99% rename from src/scanner/analyzer_queue.rs rename to crates/codex-scanner/src/analyzer_queue.rs index 395dc31d..23a671c8 100644 --- a/src/scanner/analyzer_queue.rs +++ b/crates/codex-scanner/src/analyzer_queue.rs @@ -8,8 +8,8 @@ use tokio::sync::mpsc; use tracing::{debug, error, info, warn}; use uuid::Uuid; -use crate::scanner::analyze_file; -use crate::scanner::strategies::{ +use crate::analyze_file; +use crate::strategies::{ BookMetadata, BookNamingContext, NumberContext, NumberMetadata, create_book_strategy, create_number_strategy, }; diff --git a/src/scanner/detector.rs b/crates/codex-scanner/src/detector.rs similarity index 100% rename from src/scanner/detector.rs rename to crates/codex-scanner/src/detector.rs diff --git a/src/scanner/mod.rs b/crates/codex-scanner/src/lib.rs similarity index 100% rename from src/scanner/mod.rs rename to crates/codex-scanner/src/lib.rs diff --git a/src/scanner/library_scanner.rs b/crates/codex-scanner/src/library_scanner.rs similarity index 100% rename from src/scanner/library_scanner.rs rename to crates/codex-scanner/src/library_scanner.rs diff --git a/src/scanner/strategies/book/custom.rs b/crates/codex-scanner/src/strategies/book/custom.rs similarity index 100% rename from src/scanner/strategies/book/custom.rs rename to crates/codex-scanner/src/strategies/book/custom.rs diff --git a/src/scanner/strategies/book/filename.rs b/crates/codex-scanner/src/strategies/book/filename.rs similarity index 100% rename from src/scanner/strategies/book/filename.rs rename to crates/codex-scanner/src/strategies/book/filename.rs diff --git a/src/scanner/strategies/book/metadata_first.rs b/crates/codex-scanner/src/strategies/book/metadata_first.rs similarity index 100% rename from src/scanner/strategies/book/metadata_first.rs rename to crates/codex-scanner/src/strategies/book/metadata_first.rs diff --git a/src/scanner/strategies/book/mod.rs b/crates/codex-scanner/src/strategies/book/mod.rs similarity index 100% rename from src/scanner/strategies/book/mod.rs rename to crates/codex-scanner/src/strategies/book/mod.rs diff --git a/src/scanner/strategies/book/series_name.rs b/crates/codex-scanner/src/strategies/book/series_name.rs similarity index 100% rename from src/scanner/strategies/book/series_name.rs rename to crates/codex-scanner/src/strategies/book/series_name.rs diff --git a/src/scanner/strategies/book/smart.rs b/crates/codex-scanner/src/strategies/book/smart.rs similarity index 100% rename from src/scanner/strategies/book/smart.rs rename to crates/codex-scanner/src/strategies/book/smart.rs diff --git a/src/scanner/strategies/common.rs b/crates/codex-scanner/src/strategies/common.rs similarity index 100% rename from src/scanner/strategies/common.rs rename to crates/codex-scanner/src/strategies/common.rs diff --git a/src/scanner/strategies/mod.rs b/crates/codex-scanner/src/strategies/mod.rs similarity index 100% rename from src/scanner/strategies/mod.rs rename to crates/codex-scanner/src/strategies/mod.rs diff --git a/src/scanner/strategies/number/file_order.rs b/crates/codex-scanner/src/strategies/number/file_order.rs similarity index 100% rename from src/scanner/strategies/number/file_order.rs rename to crates/codex-scanner/src/strategies/number/file_order.rs diff --git a/src/scanner/strategies/number/filename.rs b/crates/codex-scanner/src/strategies/number/filename.rs similarity index 100% rename from src/scanner/strategies/number/filename.rs rename to crates/codex-scanner/src/strategies/number/filename.rs diff --git a/src/scanner/strategies/number/metadata.rs b/crates/codex-scanner/src/strategies/number/metadata.rs similarity index 100% rename from src/scanner/strategies/number/metadata.rs rename to crates/codex-scanner/src/strategies/number/metadata.rs diff --git a/src/scanner/strategies/number/mod.rs b/crates/codex-scanner/src/strategies/number/mod.rs similarity index 100% rename from src/scanner/strategies/number/mod.rs rename to crates/codex-scanner/src/strategies/number/mod.rs diff --git a/src/scanner/strategies/number/smart.rs b/crates/codex-scanner/src/strategies/number/smart.rs similarity index 100% rename from src/scanner/strategies/number/smart.rs rename to crates/codex-scanner/src/strategies/number/smart.rs diff --git a/src/scanner/strategies/series/calibre.rs b/crates/codex-scanner/src/strategies/series/calibre.rs similarity index 100% rename from src/scanner/strategies/series/calibre.rs rename to crates/codex-scanner/src/strategies/series/calibre.rs diff --git a/src/scanner/strategies/series/custom.rs b/crates/codex-scanner/src/strategies/series/custom.rs similarity index 100% rename from src/scanner/strategies/series/custom.rs rename to crates/codex-scanner/src/strategies/series/custom.rs diff --git a/src/scanner/strategies/series/flat.rs b/crates/codex-scanner/src/strategies/series/flat.rs similarity index 100% rename from src/scanner/strategies/series/flat.rs rename to crates/codex-scanner/src/strategies/series/flat.rs diff --git a/src/scanner/strategies/series/mod.rs b/crates/codex-scanner/src/strategies/series/mod.rs similarity index 100% rename from src/scanner/strategies/series/mod.rs rename to crates/codex-scanner/src/strategies/series/mod.rs diff --git a/src/scanner/strategies/series/publisher_hierarchy.rs b/crates/codex-scanner/src/strategies/series/publisher_hierarchy.rs similarity index 100% rename from src/scanner/strategies/series/publisher_hierarchy.rs rename to crates/codex-scanner/src/strategies/series/publisher_hierarchy.rs diff --git a/src/scanner/strategies/series/series_volume.rs b/crates/codex-scanner/src/strategies/series/series_volume.rs similarity index 100% rename from src/scanner/strategies/series/series_volume.rs rename to crates/codex-scanner/src/strategies/series/series_volume.rs diff --git a/src/scanner/strategies/series/series_volume_chapter.rs b/crates/codex-scanner/src/strategies/series/series_volume_chapter.rs similarity index 100% rename from src/scanner/strategies/series/series_volume_chapter.rs rename to crates/codex-scanner/src/strategies/series/series_volume_chapter.rs diff --git a/src/scanner/types.rs b/crates/codex-scanner/src/types.rs similarity index 100% rename from src/scanner/types.rs rename to crates/codex-scanner/src/types.rs diff --git a/src/api/routes/v1/dto/scan.rs b/src/api/routes/v1/dto/scan.rs index eb17dc3f..d8034947 100644 --- a/src/api/routes/v1/dto/scan.rs +++ b/src/api/routes/v1/dto/scan.rs @@ -3,7 +3,7 @@ use serde::{Deserialize, Serialize}; use utoipa::ToSchema; use uuid::Uuid; -use crate::scanner::ScanProgress; +use codex_scanner::ScanProgress; /// Scan status response #[derive(Debug, Serialize, Deserialize, ToSchema)] @@ -155,8 +155,8 @@ pub struct AnalysisResult { pub errors: Vec<String>, } -impl From<crate::scanner::AnalysisResult> for AnalysisResult { - fn from(result: crate::scanner::AnalysisResult) -> Self { +impl From<codex_scanner::AnalysisResult> for AnalysisResult { + fn from(result: codex_scanner::AnalysisResult) -> Self { Self { books_analyzed: result.books_analyzed, errors: result.errors, diff --git a/src/api/routes/v1/handlers/libraries.rs b/src/api/routes/v1/handlers/libraries.rs index 3b24e8c9..6bfd96cd 100644 --- a/src/api/routes/v1/handlers/libraries.rs +++ b/src/api/routes/v1/handlers/libraries.rs @@ -12,7 +12,6 @@ use crate::api::{ permissions::Permission, }; use crate::require_permission; -use crate::scanner::strategies::create_strategy; use axum::{ Json, extract::{Path, Query, State}, @@ -22,6 +21,7 @@ use chrono::Utc; use codex_db::entities::libraries; use codex_db::repositories::{CreateLibraryParams, LibraryRepository}; use codex_models::{BookStrategy, NumberStrategy, SeriesStrategy}; +use codex_scanner::strategies::create_strategy; use sea_orm::DatabaseConnection; use std::sync::Arc; use uuid::Uuid; diff --git a/src/api/routes/v1/handlers/scan.rs b/src/api/routes/v1/handlers/scan.rs index 29bc402d..d8e4efb7 100644 --- a/src/api/routes/v1/handlers/scan.rs +++ b/src/api/routes/v1/handlers/scan.rs @@ -14,9 +14,9 @@ use uuid::Uuid; use super::super::dto::{ScanStatusDto, TriggerScanQuery}; use super::task_queue::CreateTaskResponse; use crate::api::{AppState, error::ApiError, extractors::AuthContext, permissions::Permission}; -use crate::scanner::ScanMode; use crate::tasks::types::TaskType; use codex_db::repositories::{BookRepository, LibraryRepository, SeriesRepository, TaskRepository}; +use codex_scanner::ScanMode; /// Trigger a library scan /// diff --git a/src/commands/scan.rs b/src/commands/scan.rs index 0d6d2678..6c5d1d6e 100644 --- a/src/commands/scan.rs +++ b/src/commands/scan.rs @@ -1,5 +1,5 @@ -use crate::scanner::{analyze_file, detect_format}; use codex_parsers::BookMetadata; +use codex_scanner::{analyze_file, detect_format}; use std::path::PathBuf; use tabled::{Table, Tabled}; diff --git a/src/lib.rs b/src/lib.rs index 9d1b827a..99a85cee 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,6 +1,5 @@ pub mod api; pub mod observability; -pub mod scanner; pub mod scheduler; pub mod tasks; pub mod web; @@ -14,6 +13,7 @@ pub use codex_db as db; pub use codex_events as events; pub use codex_models as models; pub use codex_parsers as parsers; +pub use codex_scanner as scanner; pub use codex_search as search; pub use codex_services as services; pub use codex_utils as utils; diff --git a/src/main.rs b/src/main.rs index 8b14ba70..c84cd11b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,7 +1,6 @@ mod api; mod commands; mod observability; -mod scanner; mod scheduler; mod tasks; mod web; diff --git a/src/scheduler/mod.rs b/src/scheduler/mod.rs index f6f7426d..bdc00bb2 100644 --- a/src/scheduler/mod.rs +++ b/src/scheduler/mod.rs @@ -7,10 +7,10 @@ use tokio_cron_scheduler::{Job, JobScheduler}; use tracing::{debug, error, info, warn}; use uuid::Uuid; -use crate::scanner::{ScanMode, ScanningConfig}; use crate::tasks::types::TaskType; use codex_db::entities::library_jobs; use codex_db::repositories::{LibraryJobRepository, LibraryRepository, TaskRepository}; +use codex_scanner::{ScanMode, ScanningConfig}; use codex_services::library_jobs::{LibraryJobConfig, parse_job_config}; use codex_services::settings::SettingsService; use codex_utils::cron::{normalize_cron_expression, parse_timezone}; diff --git a/src/tasks/handlers/analyze_book.rs b/src/tasks/handlers/analyze_book.rs index 04555529..ed23cf22 100644 --- a/src/tasks/handlers/analyze_book.rs +++ b/src/tasks/handlers/analyze_book.rs @@ -4,12 +4,12 @@ use serde_json::json; use std::sync::Arc; use tracing::{error, info, warn}; -use crate::scanner::analyze_book; use crate::tasks::handlers::TaskHandler; use crate::tasks::types::TaskResult; use codex_db::entities::tasks; use codex_db::repositories::BookRepository; use codex_events::{EventBroadcaster, TaskProgressEvent}; +use codex_scanner::analyze_book; pub struct AnalyzeBookHandler; diff --git a/src/tasks/handlers/renumber_series.rs b/src/tasks/handlers/renumber_series.rs index fc72d605..b5a4271c 100644 --- a/src/tasks/handlers/renumber_series.rs +++ b/src/tasks/handlers/renumber_series.rs @@ -59,7 +59,7 @@ impl TaskHandler for RenumberSeriesHandler { // Call the existing renumber function let updated_count = - crate::scanner::renumber_series_books(db, series_id, series.library_id).await?; + codex_scanner::renumber_series_books(db, series_id, series.library_id).await?; // Emit SeriesUpdated event so the frontend can refresh if updated_count > 0 diff --git a/src/tasks/handlers/scan_library.rs b/src/tasks/handlers/scan_library.rs index b43ab165..de1a101b 100644 --- a/src/tasks/handlers/scan_library.rs +++ b/src/tasks/handlers/scan_library.rs @@ -4,7 +4,6 @@ use serde_json::json; use std::sync::Arc; use tracing::{debug, error, info, warn}; -use crate::scanner::{ScanMode, ScanningConfig, scan_library}; use crate::tasks::handlers::TaskHandler; use crate::tasks::types::{TaskResult, TaskType}; use codex_db::entities::tasks; @@ -12,6 +11,7 @@ use codex_db::repositories::{ BookRepository, LibraryRepository, PluginsRepository, SeriesRepository, TaskRepository, }; use codex_events::EventBroadcaster; +use codex_scanner::{ScanMode, ScanningConfig, scan_library}; use codex_services::plugin::protocol::PluginScope; use codex_services::settings::SettingsService; From e14e465ea6595ca9af190e5cd6037bb856aa1c29 Mon Sep 17 00:00:00 2001 From: Sylvain Cau <ashdevfr@gmail.com> Date: Sat, 23 May 2026 19:46:48 -0700 Subject: [PATCH 08/14] refactor(workspace): extract codex-tasks crate Moves the task queue worker and all task handlers (scan_library, analyze_book, generate_thumbnails, refresh_library_metadata, poll_release_source, user_plugin_sync, etc.) out of the root crate into a new sibling workspace member. Depends on codex-services (PluginManager, ThumbnailService, SettingsService, OAuthStateManager, release tracking helpers) and codex-scanner (scan_library, analyze_book entry points). Pulls in codex-parsers for the thumbnail generator that opens PDFs directly. Owns its own rar feature that forwards to codex-scanner/rar so library-scan tasks include CBR files. Root rar feature now forwards to all four crates that participate in the CBR path: parsers, scanner, services, and tasks. worker.rs's metric calls (task_in_flight_inc/dec) move from the old crate::observability::metrics path to codex_services::metrics, since the metrics module relocated to codex-services in the previous commit. Root crate keeps pub use codex_tasks as tasks so callers via codex::tasks::* continue to resolve. --- Cargo.lock | 27 +++++++++++ Cargo.toml | 5 +- crates/codex-tasks/Cargo.toml | 48 +++++++++++++++++++ .../tasks => crates/codex-tasks/src}/error.rs | 0 .../codex-tasks/src}/handlers/analyze_book.rs | 4 +- .../src}/handlers/analyze_series.rs | 4 +- .../src}/handlers/backfill_tracking.rs | 4 +- .../src}/handlers/bulk_track_for_releases.rs | 6 +-- .../src}/handlers/cleanup_book_files.rs | 4 +- .../src}/handlers/cleanup_orphaned_files.rs | 4 +- .../src}/handlers/cleanup_pdf_cache.rs | 4 +- .../src}/handlers/cleanup_plugin_data.rs | 4 +- .../src}/handlers/cleanup_refresh_tokens.rs | 4 +- .../src}/handlers/cleanup_series_exports.rs | 4 +- .../src}/handlers/cleanup_series_files.rs | 4 +- .../src}/handlers/export_series.rs | 4 +- .../src}/handlers/find_duplicates.rs | 2 +- .../handlers/generate_series_thumbnail.rs | 4 +- .../handlers/generate_series_thumbnails.rs | 4 +- .../src}/handlers/generate_thumbnail.rs | 4 +- .../src}/handlers/generate_thumbnails.rs | 4 +- .../codex-tasks/src}/handlers/mod.rs | 2 +- .../src}/handlers/plugin_auto_match.rs | 4 +- .../src}/handlers/poll_release_source.rs | 4 +- .../src}/handlers/purge_deleted.rs | 4 +- .../src}/handlers/refresh_library_metadata.rs | 8 ++-- .../src}/handlers/renumber_series.rs | 4 +- .../src}/handlers/reprocess_series_titles.rs | 4 +- .../codex-tasks/src}/handlers/scan_library.rs | 4 +- .../user_plugin_recommendation_dismiss.rs | 4 +- .../handlers/user_plugin_recommendations.rs | 4 +- .../src}/handlers/user_plugin_sync/mod.rs | 4 +- .../src}/handlers/user_plugin_sync/pull.rs | 0 .../src}/handlers/user_plugin_sync/push.rs | 0 .../handlers/user_plugin_sync/settings.rs | 0 .../src}/handlers/user_plugin_sync/tests.rs | 0 .../mod.rs => crates/codex-tasks/src/lib.rs | 0 .../tasks => crates/codex-tasks/src}/types.rs | 2 +- .../codex-tasks/src}/worker.rs | 14 +++--- src/api/docs.rs | 6 +-- src/api/routes/v1/handlers/books.rs | 2 +- src/api/routes/v1/handlers/bulk.rs | 2 +- src/api/routes/v1/handlers/cleanup.rs | 2 +- src/api/routes/v1/handlers/duplicates.rs | 2 +- src/api/routes/v1/handlers/libraries.rs | 2 +- src/api/routes/v1/handlers/library_jobs.rs | 2 +- src/api/routes/v1/handlers/pdf_cache.rs | 2 +- src/api/routes/v1/handlers/plugin_actions.rs | 2 +- src/api/routes/v1/handlers/recommendations.rs | 2 +- src/api/routes/v1/handlers/scan.rs | 2 +- src/api/routes/v1/handlers/series.rs | 4 +- src/api/routes/v1/handlers/series_exports.rs | 2 +- src/api/routes/v1/handlers/task_queue.rs | 2 +- src/api/routes/v1/handlers/user_plugins.rs | 4 +- src/commands/common.rs | 2 +- src/commands/serve.rs | 2 +- src/lib.rs | 2 +- src/main.rs | 1 - src/scheduler/mod.rs | 4 +- src/scheduler/release_sources.rs | 2 +- 60 files changed, 169 insertions(+), 92 deletions(-) create mode 100644 crates/codex-tasks/Cargo.toml rename {src/tasks => crates/codex-tasks/src}/error.rs (100%) rename {src/tasks => crates/codex-tasks/src}/handlers/analyze_book.rs (97%) rename {src/tasks => crates/codex-tasks/src}/handlers/analyze_series.rs (97%) rename {src/tasks => crates/codex-tasks/src}/handlers/backfill_tracking.rs (99%) rename {src/tasks => crates/codex-tasks/src}/handlers/bulk_track_for_releases.rs (99%) rename {src/tasks => crates/codex-tasks/src}/handlers/cleanup_book_files.rs (99%) rename {src/tasks => crates/codex-tasks/src}/handlers/cleanup_orphaned_files.rs (98%) rename {src/tasks => crates/codex-tasks/src}/handlers/cleanup_pdf_cache.rs (98%) rename {src/tasks => crates/codex-tasks/src}/handlers/cleanup_plugin_data.rs (98%) rename {src/tasks => crates/codex-tasks/src}/handlers/cleanup_refresh_tokens.rs (98%) rename {src/tasks => crates/codex-tasks/src}/handlers/cleanup_series_exports.rs (99%) rename {src/tasks => crates/codex-tasks/src}/handlers/cleanup_series_files.rs (98%) rename {src/tasks => crates/codex-tasks/src}/handlers/export_series.rs (99%) rename {src/tasks => crates/codex-tasks/src}/handlers/find_duplicates.rs (98%) rename {src/tasks => crates/codex-tasks/src}/handlers/generate_series_thumbnail.rs (99%) rename {src/tasks => crates/codex-tasks/src}/handlers/generate_series_thumbnails.rs (98%) rename {src/tasks => crates/codex-tasks/src}/handlers/generate_thumbnail.rs (98%) rename {src/tasks => crates/codex-tasks/src}/handlers/generate_thumbnails.rs (98%) rename {src/tasks => crates/codex-tasks/src}/handlers/mod.rs (98%) rename {src/tasks => crates/codex-tasks/src}/handlers/plugin_auto_match.rs (99%) rename {src/tasks => crates/codex-tasks/src}/handlers/poll_release_source.rs (99%) rename {src/tasks => crates/codex-tasks/src}/handlers/purge_deleted.rs (96%) rename {src/tasks => crates/codex-tasks/src}/handlers/refresh_library_metadata.rs (99%) rename {src/tasks => crates/codex-tasks/src}/handlers/renumber_series.rs (98%) rename {src/tasks => crates/codex-tasks/src}/handlers/reprocess_series_titles.rs (99%) rename {src/tasks => crates/codex-tasks/src}/handlers/scan_library.rs (99%) rename {src/tasks => crates/codex-tasks/src}/handlers/user_plugin_recommendation_dismiss.rs (98%) rename {src/tasks => crates/codex-tasks/src}/handlers/user_plugin_recommendations.rs (99%) rename {src/tasks => crates/codex-tasks/src}/handlers/user_plugin_sync/mod.rs (99%) rename {src/tasks => crates/codex-tasks/src}/handlers/user_plugin_sync/pull.rs (100%) rename {src/tasks => crates/codex-tasks/src}/handlers/user_plugin_sync/push.rs (100%) rename {src/tasks => crates/codex-tasks/src}/handlers/user_plugin_sync/settings.rs (100%) rename {src/tasks => crates/codex-tasks/src}/handlers/user_plugin_sync/tests.rs (100%) rename src/tasks/mod.rs => crates/codex-tasks/src/lib.rs (100%) rename {src/tasks => crates/codex-tasks/src}/types.rs (76%) rename {src/tasks => crates/codex-tasks/src}/worker.rs (99%) diff --git a/Cargo.lock b/Cargo.lock index 5f2e5db3..e3285a78 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -829,6 +829,7 @@ dependencies = [ "codex-scanner", "codex-search", "codex-services", + "codex-tasks", "codex-utils", "cron", "csv", @@ -1080,6 +1081,32 @@ dependencies = [ "uuid", ] +[[package]] +name = "codex-tasks" +version = "0.0.0" +dependencies = [ + "anyhow", + "chrono", + "codex-config", + "codex-db", + "codex-events", + "codex-models", + "codex-parsers", + "codex-scanner", + "codex-services", + "codex-utils", + "futures", + "sea-orm", + "serde", + "serde_json", + "serial_test", + "tempfile", + "tokio", + "tracing", + "utoipa", + "uuid", +] + [[package]] name = "codex-utils" version = "0.0.0" diff --git a/Cargo.toml b/Cargo.toml index 46342bf0..0c5c0266 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,7 +14,7 @@ path = "src/main.rs" [features] default = ["rar", "observability"] -rar = ["codex-parsers/rar", "codex-scanner/rar", "codex-services/rar"] +rar = ["codex-parsers/rar", "codex-scanner/rar", "codex-services/rar", "codex-tasks/rar"] embed-frontend = [] observability = [ "dep:opentelemetry", @@ -41,6 +41,7 @@ members = [ "crates/codex-services", "crates/codex-search", "crates/codex-scanner", + "crates/codex-tasks", ] # Shared dependencies inherited by workspace members. Only deps that are @@ -71,6 +72,7 @@ codex-parsers = { path = "crates/codex-parsers", default-features = false } codex-scanner = { path = "crates/codex-scanner", default-features = false } codex-search = { path = "crates/codex-search" } codex-services = { path = "crates/codex-services" } +codex-tasks = { path = "crates/codex-tasks", default-features = false } codex-utils = { path = "crates/codex-utils" } # Shared dev-dependencies @@ -156,6 +158,7 @@ codex-parsers = { workspace = true } codex-scanner = { workspace = true } codex-search = { workspace = true } codex-services = { workspace = true } +codex-tasks = { workspace = true } codex-utils = { workspace = true } tokio = { workspace = true } uuid = { workspace = true } diff --git a/crates/codex-tasks/Cargo.toml b/crates/codex-tasks/Cargo.toml new file mode 100644 index 00000000..f2178f5c --- /dev/null +++ b/crates/codex-tasks/Cargo.toml @@ -0,0 +1,48 @@ +[package] +name = "codex-tasks" +version = "0.0.0" +edition = "2024" +publish = false + +[lib] +name = "codex_tasks" +path = "src/lib.rs" + +[features] +default = [] +# Forwards to codex-scanner/rar so library-scan tasks include CBR files. +rar = ["codex-scanner/rar"] + +[dependencies] +anyhow = { workspace = true } +chrono = { workspace = true } +serde = { workspace = true } +tokio = { workspace = true } +tracing = { workspace = true } +utoipa = { workspace = true } +uuid = { workspace = true } + +codex-config = { workspace = true } +codex-db = { workspace = true } +codex-events = { workspace = true } +codex-models = { workspace = true } +codex-parsers = { workspace = true } +codex-scanner = { workspace = true } +codex-services = { workspace = true } +codex-utils = { workspace = true } + +sea-orm = { version = "1.1", features = [ + "sqlx-postgres", + "sqlx-sqlite", + "runtime-tokio-rustls", + "macros", + "with-chrono", + "with-uuid", +] } +serde_json = "1.0" +futures = "0.3" + +[dev-dependencies] +tempfile = { workspace = true } +serial_test = { workspace = true } +codex-db = { workspace = true, features = ["test-utils"] } diff --git a/src/tasks/error.rs b/crates/codex-tasks/src/error.rs similarity index 100% rename from src/tasks/error.rs rename to crates/codex-tasks/src/error.rs diff --git a/src/tasks/handlers/analyze_book.rs b/crates/codex-tasks/src/handlers/analyze_book.rs similarity index 97% rename from src/tasks/handlers/analyze_book.rs rename to crates/codex-tasks/src/handlers/analyze_book.rs index ed23cf22..f0b19ee5 100644 --- a/src/tasks/handlers/analyze_book.rs +++ b/crates/codex-tasks/src/handlers/analyze_book.rs @@ -4,8 +4,8 @@ use serde_json::json; use std::sync::Arc; use tracing::{error, info, warn}; -use crate::tasks::handlers::TaskHandler; -use crate::tasks::types::TaskResult; +use crate::handlers::TaskHandler; +use crate::types::TaskResult; use codex_db::entities::tasks; use codex_db::repositories::BookRepository; use codex_events::{EventBroadcaster, TaskProgressEvent}; diff --git a/src/tasks/handlers/analyze_series.rs b/crates/codex-tasks/src/handlers/analyze_series.rs similarity index 97% rename from src/tasks/handlers/analyze_series.rs rename to crates/codex-tasks/src/handlers/analyze_series.rs index 52d8f45e..09ad8d05 100644 --- a/src/tasks/handlers/analyze_series.rs +++ b/crates/codex-tasks/src/handlers/analyze_series.rs @@ -4,8 +4,8 @@ use serde_json::json; use std::sync::Arc; use tracing::{error, info}; -use crate::tasks::handlers::TaskHandler; -use crate::tasks::types::{TaskResult, TaskType}; +use crate::handlers::TaskHandler; +use crate::types::{TaskResult, TaskType}; use codex_db::entities::tasks; use codex_db::repositories::{BookRepository, TaskRepository}; use codex_events::{EventBroadcaster, TaskProgressEvent}; diff --git a/src/tasks/handlers/backfill_tracking.rs b/crates/codex-tasks/src/handlers/backfill_tracking.rs similarity index 99% rename from src/tasks/handlers/backfill_tracking.rs rename to crates/codex-tasks/src/handlers/backfill_tracking.rs index 8839de94..1c13b905 100644 --- a/src/tasks/handlers/backfill_tracking.rs +++ b/crates/codex-tasks/src/handlers/backfill_tracking.rs @@ -17,8 +17,8 @@ use std::sync::Arc; use tracing::{info, warn}; use uuid::Uuid; -use crate::tasks::handlers::TaskHandler; -use crate::tasks::types::TaskResult; +use crate::handlers::TaskHandler; +use crate::types::TaskResult; use codex_db::entities::tasks; use codex_db::repositories::SeriesRepository; use codex_events::EventBroadcaster; diff --git a/src/tasks/handlers/bulk_track_for_releases.rs b/crates/codex-tasks/src/handlers/bulk_track_for_releases.rs similarity index 99% rename from src/tasks/handlers/bulk_track_for_releases.rs rename to crates/codex-tasks/src/handlers/bulk_track_for_releases.rs index 5072fb50..488dd11c 100644 --- a/src/tasks/handlers/bulk_track_for_releases.rs +++ b/crates/codex-tasks/src/handlers/bulk_track_for_releases.rs @@ -17,8 +17,8 @@ use std::sync::Arc; use tracing::{info, warn}; use uuid::Uuid; -use crate::tasks::handlers::TaskHandler; -use crate::tasks::types::TaskResult; +use crate::handlers::TaskHandler; +use crate::types::TaskResult; use codex_db::entities::tasks; use codex_events::{EventBroadcaster, TaskProgressEvent}; use codex_services::release::tracking_toggle::{ @@ -194,7 +194,7 @@ fn emit_progress( mod tests { use super::*; - use crate::tasks::types::TaskType; + use crate::types::TaskType; use codex_db::ScanningStrategy; use codex_db::repositories::{ LibraryRepository, SeriesAliasRepository, SeriesRepository, SeriesTrackingRepository, diff --git a/src/tasks/handlers/cleanup_book_files.rs b/crates/codex-tasks/src/handlers/cleanup_book_files.rs similarity index 99% rename from src/tasks/handlers/cleanup_book_files.rs rename to crates/codex-tasks/src/handlers/cleanup_book_files.rs index f9f2a4c9..7bff8f2c 100644 --- a/src/tasks/handlers/cleanup_book_files.rs +++ b/crates/codex-tasks/src/handlers/cleanup_book_files.rs @@ -12,8 +12,8 @@ use std::path::PathBuf; use std::sync::Arc; use tracing::{debug, info, warn}; -use crate::tasks::handlers::TaskHandler; -use crate::tasks::types::TaskResult; +use crate::handlers::TaskHandler; +use crate::types::TaskResult; use codex_config::FilesConfig; use codex_db::entities::tasks; use codex_events::EventBroadcaster; diff --git a/src/tasks/handlers/cleanup_orphaned_files.rs b/crates/codex-tasks/src/handlers/cleanup_orphaned_files.rs similarity index 98% rename from src/tasks/handlers/cleanup_orphaned_files.rs rename to crates/codex-tasks/src/handlers/cleanup_orphaned_files.rs index 56b8c1fa..3618bc5e 100644 --- a/src/tasks/handlers/cleanup_orphaned_files.rs +++ b/crates/codex-tasks/src/handlers/cleanup_orphaned_files.rs @@ -10,8 +10,8 @@ use serde_json::json; use std::sync::Arc; use tracing::{debug, info}; -use crate::tasks::handlers::TaskHandler; -use crate::tasks::types::TaskResult; +use crate::handlers::TaskHandler; +use crate::types::TaskResult; use codex_config::FilesConfig; use codex_db::entities::tasks; use codex_db::repositories::{BookRepository, SeriesRepository}; diff --git a/src/tasks/handlers/cleanup_pdf_cache.rs b/crates/codex-tasks/src/handlers/cleanup_pdf_cache.rs similarity index 98% rename from src/tasks/handlers/cleanup_pdf_cache.rs rename to crates/codex-tasks/src/handlers/cleanup_pdf_cache.rs index d9fda503..f1fb2fae 100644 --- a/src/tasks/handlers/cleanup_pdf_cache.rs +++ b/crates/codex-tasks/src/handlers/cleanup_pdf_cache.rs @@ -9,8 +9,8 @@ use serde_json::json; use std::sync::Arc; use tracing::info; -use crate::tasks::handlers::TaskHandler; -use crate::tasks::types::TaskResult; +use crate::handlers::TaskHandler; +use crate::types::TaskResult; use codex_db::entities::tasks; use codex_events::EventBroadcaster; use codex_services::{PdfPageCache, SettingsService}; diff --git a/src/tasks/handlers/cleanup_plugin_data.rs b/crates/codex-tasks/src/handlers/cleanup_plugin_data.rs similarity index 98% rename from src/tasks/handlers/cleanup_plugin_data.rs rename to crates/codex-tasks/src/handlers/cleanup_plugin_data.rs index e79b4b9b..0004a51f 100644 --- a/src/tasks/handlers/cleanup_plugin_data.rs +++ b/crates/codex-tasks/src/handlers/cleanup_plugin_data.rs @@ -11,8 +11,8 @@ use serde_json::json; use std::sync::Arc; use tracing::info; -use crate::tasks::handlers::TaskHandler; -use crate::tasks::types::TaskResult; +use crate::handlers::TaskHandler; +use crate::types::TaskResult; use codex_db::entities::tasks; use codex_db::repositories::UserPluginDataRepository; use codex_events::EventBroadcaster; diff --git a/src/tasks/handlers/cleanup_refresh_tokens.rs b/crates/codex-tasks/src/handlers/cleanup_refresh_tokens.rs similarity index 98% rename from src/tasks/handlers/cleanup_refresh_tokens.rs rename to crates/codex-tasks/src/handlers/cleanup_refresh_tokens.rs index 51be2498..35de0465 100644 --- a/src/tasks/handlers/cleanup_refresh_tokens.rs +++ b/crates/codex-tasks/src/handlers/cleanup_refresh_tokens.rs @@ -11,8 +11,8 @@ use serde_json::json; use std::sync::Arc; use tracing::info; -use crate::tasks::handlers::TaskHandler; -use crate::tasks::types::TaskResult; +use crate::handlers::TaskHandler; +use crate::types::TaskResult; use codex_db::entities::tasks; use codex_db::repositories::RefreshTokenRepository; use codex_events::EventBroadcaster; diff --git a/src/tasks/handlers/cleanup_series_exports.rs b/crates/codex-tasks/src/handlers/cleanup_series_exports.rs similarity index 99% rename from src/tasks/handlers/cleanup_series_exports.rs rename to crates/codex-tasks/src/handlers/cleanup_series_exports.rs index a33c90a9..9edf3cab 100644 --- a/src/tasks/handlers/cleanup_series_exports.rs +++ b/crates/codex-tasks/src/handlers/cleanup_series_exports.rs @@ -11,8 +11,8 @@ use serde_json::json; use std::sync::Arc; use tracing::{info, warn}; -use crate::tasks::handlers::TaskHandler; -use crate::tasks::types::TaskResult; +use crate::handlers::TaskHandler; +use crate::types::TaskResult; use codex_db::entities::tasks; use codex_db::repositories::SeriesExportRepository; use codex_events::{EventBroadcaster, TaskProgressEvent}; diff --git a/src/tasks/handlers/cleanup_series_files.rs b/crates/codex-tasks/src/handlers/cleanup_series_files.rs similarity index 98% rename from src/tasks/handlers/cleanup_series_files.rs rename to crates/codex-tasks/src/handlers/cleanup_series_files.rs index 40b22f22..871e61d0 100644 --- a/src/tasks/handlers/cleanup_series_files.rs +++ b/crates/codex-tasks/src/handlers/cleanup_series_files.rs @@ -9,8 +9,8 @@ use serde_json::json; use std::sync::Arc; use tracing::{debug, info, warn}; -use crate::tasks::handlers::TaskHandler; -use crate::tasks::types::TaskResult; +use crate::handlers::TaskHandler; +use crate::types::TaskResult; use codex_config::FilesConfig; use codex_db::entities::tasks; use codex_events::EventBroadcaster; diff --git a/src/tasks/handlers/export_series.rs b/crates/codex-tasks/src/handlers/export_series.rs similarity index 99% rename from src/tasks/handlers/export_series.rs rename to crates/codex-tasks/src/handlers/export_series.rs index 39a1b487..44543423 100644 --- a/src/tasks/handlers/export_series.rs +++ b/crates/codex-tasks/src/handlers/export_series.rs @@ -13,8 +13,8 @@ use std::sync::Arc; use tracing::{error, info, warn}; use uuid::Uuid; -use crate::tasks::handlers::TaskHandler; -use crate::tasks::types::TaskResult; +use crate::handlers::TaskHandler; +use crate::types::TaskResult; use codex_db::entities::tasks; use codex_db::repositories::SeriesExportRepository; use codex_events::{EventBroadcaster, TaskProgressEvent}; diff --git a/src/tasks/handlers/find_duplicates.rs b/crates/codex-tasks/src/handlers/find_duplicates.rs similarity index 98% rename from src/tasks/handlers/find_duplicates.rs rename to crates/codex-tasks/src/handlers/find_duplicates.rs index f96855b5..288cda92 100644 --- a/src/tasks/handlers/find_duplicates.rs +++ b/crates/codex-tasks/src/handlers/find_duplicates.rs @@ -3,7 +3,7 @@ use sea_orm::DatabaseConnection; use std::sync::Arc; use tracing::{info, warn}; -use crate::tasks::types::TaskResult; +use crate::types::TaskResult; use codex_db::entities::tasks; use codex_db::repositories::{ BookDuplicatesRepository, SeriesDuplicatesRepository, SettingsRepository, diff --git a/src/tasks/handlers/generate_series_thumbnail.rs b/crates/codex-tasks/src/handlers/generate_series_thumbnail.rs similarity index 99% rename from src/tasks/handlers/generate_series_thumbnail.rs rename to crates/codex-tasks/src/handlers/generate_series_thumbnail.rs index 1e6479ec..d4a413d8 100644 --- a/src/tasks/handlers/generate_series_thumbnail.rs +++ b/crates/codex-tasks/src/handlers/generate_series_thumbnail.rs @@ -8,8 +8,8 @@ use sea_orm::DatabaseConnection; use std::sync::Arc; use tracing::{debug, info, warn}; -use crate::tasks::handlers::TaskHandler; -use crate::tasks::types::TaskResult; +use crate::handlers::TaskHandler; +use crate::types::TaskResult; use codex_db::entities::tasks; use codex_db::repositories::{BookRepository, SeriesCoversRepository, SeriesRepository}; use codex_events::{EntityChangeEvent, EntityEvent, EntityType, EventBroadcaster}; diff --git a/src/tasks/handlers/generate_series_thumbnails.rs b/crates/codex-tasks/src/handlers/generate_series_thumbnails.rs similarity index 98% rename from src/tasks/handlers/generate_series_thumbnails.rs rename to crates/codex-tasks/src/handlers/generate_series_thumbnails.rs index f0dd5783..6317adb1 100644 --- a/src/tasks/handlers/generate_series_thumbnails.rs +++ b/crates/codex-tasks/src/handlers/generate_series_thumbnails.rs @@ -9,8 +9,8 @@ use sea_orm::DatabaseConnection; use std::sync::Arc; use tracing::{debug, info, warn}; -use crate::tasks::handlers::TaskHandler; -use crate::tasks::types::{TaskResult, TaskType}; +use crate::handlers::TaskHandler; +use crate::types::{TaskResult, TaskType}; use codex_db::entities::tasks; use codex_db::repositories::{SeriesRepository, TaskRepository}; use codex_events::{EventBroadcaster, TaskProgressEvent}; diff --git a/src/tasks/handlers/generate_thumbnail.rs b/crates/codex-tasks/src/handlers/generate_thumbnail.rs similarity index 98% rename from src/tasks/handlers/generate_thumbnail.rs rename to crates/codex-tasks/src/handlers/generate_thumbnail.rs index ef1250a0..ccd4702f 100644 --- a/src/tasks/handlers/generate_thumbnail.rs +++ b/crates/codex-tasks/src/handlers/generate_thumbnail.rs @@ -3,8 +3,8 @@ use sea_orm::DatabaseConnection; use std::sync::Arc; use tracing::{debug, error, info, warn}; -use crate::tasks::handlers::TaskHandler; -use crate::tasks::types::{TaskResult, TaskType}; +use crate::handlers::TaskHandler; +use crate::types::{TaskResult, TaskType}; use codex_db::entities::book_error::{BookError, BookErrorType}; use codex_db::entities::tasks; use codex_db::repositories::{BookRepository, SeriesRepository, TaskRepository}; diff --git a/src/tasks/handlers/generate_thumbnails.rs b/crates/codex-tasks/src/handlers/generate_thumbnails.rs similarity index 98% rename from src/tasks/handlers/generate_thumbnails.rs rename to crates/codex-tasks/src/handlers/generate_thumbnails.rs index a05a82eb..02281b21 100644 --- a/src/tasks/handlers/generate_thumbnails.rs +++ b/crates/codex-tasks/src/handlers/generate_thumbnails.rs @@ -3,8 +3,8 @@ use sea_orm::DatabaseConnection; use std::sync::Arc; use tracing::{debug, info, warn}; -use crate::tasks::handlers::TaskHandler; -use crate::tasks::types::{TaskResult, TaskType}; +use crate::handlers::TaskHandler; +use crate::types::{TaskResult, TaskType}; use codex_db::entities::tasks; use codex_db::repositories::{BookRepository, TaskRepository}; use codex_events::{EventBroadcaster, TaskProgressEvent}; diff --git a/src/tasks/handlers/mod.rs b/crates/codex-tasks/src/handlers/mod.rs similarity index 98% rename from src/tasks/handlers/mod.rs rename to crates/codex-tasks/src/handlers/mod.rs index d1d611d2..6ea32538 100644 --- a/src/tasks/handlers/mod.rs +++ b/crates/codex-tasks/src/handlers/mod.rs @@ -2,7 +2,7 @@ use anyhow::Result; use sea_orm::DatabaseConnection; use std::sync::Arc; -use crate::tasks::types::TaskResult; +use crate::types::TaskResult; use codex_db::entities::tasks; use codex_events::EventBroadcaster; diff --git a/src/tasks/handlers/plugin_auto_match.rs b/crates/codex-tasks/src/handlers/plugin_auto_match.rs similarity index 99% rename from src/tasks/handlers/plugin_auto_match.rs rename to crates/codex-tasks/src/handlers/plugin_auto_match.rs index 058d7be6..b6b43e1e 100644 --- a/src/tasks/handlers/plugin_auto_match.rs +++ b/crates/codex-tasks/src/handlers/plugin_auto_match.rs @@ -19,8 +19,8 @@ use std::sync::Arc; use tracing::{debug, error, info, warn}; use uuid::Uuid; -use crate::tasks::handlers::TaskHandler; -use crate::tasks::types::TaskResult; +use crate::handlers::TaskHandler; +use crate::types::TaskResult; use codex_db::entities::tasks; use codex_db::repositories::{ BookExternalIdRepository, BookMetadataRepository, BookRepository, LibraryRepository, diff --git a/src/tasks/handlers/poll_release_source.rs b/crates/codex-tasks/src/handlers/poll_release_source.rs similarity index 99% rename from src/tasks/handlers/poll_release_source.rs rename to crates/codex-tasks/src/handlers/poll_release_source.rs index a6307596..dd1d945c 100644 --- a/src/tasks/handlers/poll_release_source.rs +++ b/crates/codex-tasks/src/handlers/poll_release_source.rs @@ -33,8 +33,8 @@ use std::time::Duration; use tracing::{debug, error, info, warn}; use uuid::Uuid; -use crate::tasks::handlers::TaskHandler; -use crate::tasks::types::TaskResult; +use crate::handlers::TaskHandler; +use crate::types::TaskResult; use codex_db::entities::release_ledger::state as ledger_state; use codex_db::entities::release_sources::plugin_id as source_plugin_id; use codex_db::entities::tasks; diff --git a/src/tasks/handlers/purge_deleted.rs b/crates/codex-tasks/src/handlers/purge_deleted.rs similarity index 96% rename from src/tasks/handlers/purge_deleted.rs rename to crates/codex-tasks/src/handlers/purge_deleted.rs index f9b6fd63..4120660d 100644 --- a/src/tasks/handlers/purge_deleted.rs +++ b/crates/codex-tasks/src/handlers/purge_deleted.rs @@ -4,8 +4,8 @@ use serde_json::json; use std::sync::Arc; use tracing::{error, info}; -use crate::tasks::handlers::TaskHandler; -use crate::tasks::types::TaskResult; +use crate::handlers::TaskHandler; +use crate::types::TaskResult; use codex_db::entities::tasks; use codex_db::repositories::BookRepository; use codex_events::EventBroadcaster; diff --git a/src/tasks/handlers/refresh_library_metadata.rs b/crates/codex-tasks/src/handlers/refresh_library_metadata.rs similarity index 99% rename from src/tasks/handlers/refresh_library_metadata.rs rename to crates/codex-tasks/src/handlers/refresh_library_metadata.rs index a7a76b03..84dc3f29 100644 --- a/src/tasks/handlers/refresh_library_metadata.rs +++ b/crates/codex-tasks/src/handlers/refresh_library_metadata.rs @@ -20,8 +20,8 @@ use std::sync::Arc; use std::time::Duration; use tracing::{debug, error, info, warn}; -use crate::tasks::handlers::TaskHandler; -use crate::tasks::types::TaskResult; +use crate::handlers::TaskHandler; +use crate::types::TaskResult; use codex_db::entities::tasks; use codex_db::repositories::{ LibraryJobRepository, LibraryRepository, PluginsRepository, RecordRunStatus, @@ -76,7 +76,7 @@ impl RunSummary { } } -/// Handler for [`crate::tasks::types::TaskType::RefreshLibraryMetadata`]. +/// Handler for [`crate::types::TaskType::RefreshLibraryMetadata`]. pub struct RefreshLibraryMetadataHandler { plugin_manager: Arc<PluginManager>, thumbnail_service: Option<Arc<ThumbnailService>>, @@ -533,7 +533,7 @@ async fn rematch_external_id( #[cfg(test)] mod tests { use super::*; - use crate::tasks::types::TaskType; + use crate::types::TaskType; use codex_db::ScanningStrategy; use codex_db::entities::plugins::PluginPermission; use codex_db::repositories::{ diff --git a/src/tasks/handlers/renumber_series.rs b/crates/codex-tasks/src/handlers/renumber_series.rs similarity index 98% rename from src/tasks/handlers/renumber_series.rs rename to crates/codex-tasks/src/handlers/renumber_series.rs index b5a4271c..38191dfa 100644 --- a/src/tasks/handlers/renumber_series.rs +++ b/crates/codex-tasks/src/handlers/renumber_series.rs @@ -11,8 +11,8 @@ use std::sync::Arc; use tracing::{debug, info, warn}; use uuid::Uuid; -use crate::tasks::handlers::TaskHandler; -use crate::tasks::types::{TaskResult, TaskType}; +use crate::handlers::TaskHandler; +use crate::types::{TaskResult, TaskType}; use codex_db::entities::tasks; use codex_db::repositories::{SeriesRepository, TaskRepository}; use codex_events::{EntityChangeEvent, EntityEvent, EventBroadcaster, TaskProgressEvent}; diff --git a/src/tasks/handlers/reprocess_series_titles.rs b/crates/codex-tasks/src/handlers/reprocess_series_titles.rs similarity index 99% rename from src/tasks/handlers/reprocess_series_titles.rs rename to crates/codex-tasks/src/handlers/reprocess_series_titles.rs index ae7adc55..e1d913ab 100644 --- a/src/tasks/handlers/reprocess_series_titles.rs +++ b/crates/codex-tasks/src/handlers/reprocess_series_titles.rs @@ -10,8 +10,8 @@ use std::sync::Arc; use tracing::{debug, info, warn}; use uuid::Uuid; -use crate::tasks::handlers::TaskHandler; -use crate::tasks::types::{TaskResult, TaskType}; +use crate::handlers::TaskHandler; +use crate::types::{TaskResult, TaskType}; use codex_db::entities::{series_metadata, tasks}; use codex_db::repositories::{ LibraryRepository, SeriesMetadataRepository, SeriesRepository, TaskRepository, diff --git a/src/tasks/handlers/scan_library.rs b/crates/codex-tasks/src/handlers/scan_library.rs similarity index 99% rename from src/tasks/handlers/scan_library.rs rename to crates/codex-tasks/src/handlers/scan_library.rs index de1a101b..09bbd07a 100644 --- a/src/tasks/handlers/scan_library.rs +++ b/crates/codex-tasks/src/handlers/scan_library.rs @@ -4,8 +4,8 @@ use serde_json::json; use std::sync::Arc; use tracing::{debug, error, info, warn}; -use crate::tasks::handlers::TaskHandler; -use crate::tasks::types::{TaskResult, TaskType}; +use crate::handlers::TaskHandler; +use crate::types::{TaskResult, TaskType}; use codex_db::entities::tasks; use codex_db::repositories::{ BookRepository, LibraryRepository, PluginsRepository, SeriesRepository, TaskRepository, diff --git a/src/tasks/handlers/user_plugin_recommendation_dismiss.rs b/crates/codex-tasks/src/handlers/user_plugin_recommendation_dismiss.rs similarity index 98% rename from src/tasks/handlers/user_plugin_recommendation_dismiss.rs rename to crates/codex-tasks/src/handlers/user_plugin_recommendation_dismiss.rs index 24ea5c18..b00440b3 100644 --- a/src/tasks/handlers/user_plugin_recommendation_dismiss.rs +++ b/crates/codex-tasks/src/handlers/user_plugin_recommendation_dismiss.rs @@ -11,8 +11,8 @@ use std::time::Duration; use tracing::{debug, info, warn}; use uuid::Uuid; -use crate::tasks::handlers::TaskHandler; -use crate::tasks::types::TaskResult; +use crate::handlers::TaskHandler; +use crate::types::TaskResult; use codex_db::entities::tasks; use codex_events::EventBroadcaster; use codex_services::SettingsService; diff --git a/src/tasks/handlers/user_plugin_recommendations.rs b/crates/codex-tasks/src/handlers/user_plugin_recommendations.rs similarity index 99% rename from src/tasks/handlers/user_plugin_recommendations.rs rename to crates/codex-tasks/src/handlers/user_plugin_recommendations.rs index ba52dca0..5cf20c07 100644 --- a/src/tasks/handlers/user_plugin_recommendations.rs +++ b/crates/codex-tasks/src/handlers/user_plugin_recommendations.rs @@ -14,8 +14,8 @@ use std::time::Duration; use tracing::{debug, info, warn}; use uuid::Uuid; -use crate::tasks::handlers::TaskHandler; -use crate::tasks::types::TaskResult; +use crate::handlers::TaskHandler; +use crate::types::TaskResult; use codex_db::entities::tasks; use codex_db::repositories::{PluginsRepository, UserPluginDataRepository, UserPluginsRepository}; use codex_events::EventBroadcaster; diff --git a/src/tasks/handlers/user_plugin_sync/mod.rs b/crates/codex-tasks/src/handlers/user_plugin_sync/mod.rs similarity index 99% rename from src/tasks/handlers/user_plugin_sync/mod.rs rename to crates/codex-tasks/src/handlers/user_plugin_sync/mod.rs index 9da22830..0815b97f 100644 --- a/src/tasks/handlers/user_plugin_sync/mod.rs +++ b/crates/codex-tasks/src/handlers/user_plugin_sync/mod.rs @@ -25,8 +25,8 @@ use std::time::Duration; use tracing::{debug, error, info, warn}; use uuid::Uuid; -use crate::tasks::handlers::TaskHandler; -use crate::tasks::types::TaskResult; +use crate::handlers::TaskHandler; +use crate::types::TaskResult; use codex_db::entities::tasks; use codex_db::repositories::{UserPluginDataRepository, UserPluginsRepository}; use codex_events::{EventBroadcaster, TaskProgressEvent}; diff --git a/src/tasks/handlers/user_plugin_sync/pull.rs b/crates/codex-tasks/src/handlers/user_plugin_sync/pull.rs similarity index 100% rename from src/tasks/handlers/user_plugin_sync/pull.rs rename to crates/codex-tasks/src/handlers/user_plugin_sync/pull.rs diff --git a/src/tasks/handlers/user_plugin_sync/push.rs b/crates/codex-tasks/src/handlers/user_plugin_sync/push.rs similarity index 100% rename from src/tasks/handlers/user_plugin_sync/push.rs rename to crates/codex-tasks/src/handlers/user_plugin_sync/push.rs diff --git a/src/tasks/handlers/user_plugin_sync/settings.rs b/crates/codex-tasks/src/handlers/user_plugin_sync/settings.rs similarity index 100% rename from src/tasks/handlers/user_plugin_sync/settings.rs rename to crates/codex-tasks/src/handlers/user_plugin_sync/settings.rs diff --git a/src/tasks/handlers/user_plugin_sync/tests.rs b/crates/codex-tasks/src/handlers/user_plugin_sync/tests.rs similarity index 100% rename from src/tasks/handlers/user_plugin_sync/tests.rs rename to crates/codex-tasks/src/handlers/user_plugin_sync/tests.rs diff --git a/src/tasks/mod.rs b/crates/codex-tasks/src/lib.rs similarity index 100% rename from src/tasks/mod.rs rename to crates/codex-tasks/src/lib.rs diff --git a/src/tasks/types.rs b/crates/codex-tasks/src/types.rs similarity index 76% rename from src/tasks/types.rs rename to crates/codex-tasks/src/types.rs index 62cee098..fdd38d75 100644 --- a/src/tasks/types.rs +++ b/crates/codex-tasks/src/types.rs @@ -1,7 +1,7 @@ //! Re-export of task value types. //! //! The canonical home is [`codex_models::task`]. This module keeps the -//! `crate::tasks::types::*` path working for tests and downstream code while +//! `crate::types::*` path working for tests and downstream code while //! the data shapes live in `models` so non-tasks layers can speak them //! without depending on the tasks layer. diff --git a/src/tasks/worker.rs b/crates/codex-tasks/src/worker.rs similarity index 99% rename from src/tasks/worker.rs rename to crates/codex-tasks/src/worker.rs index 794a9b41..c2dd679a 100644 --- a/src/tasks/worker.rs +++ b/crates/codex-tasks/src/worker.rs @@ -16,8 +16,8 @@ use tokio::time::sleep; use tracing::{debug, error, info, warn}; use uuid::Uuid; -use crate::tasks::error::check_rate_limited; -use crate::tasks::handlers::{ +use crate::error::check_rate_limited; +use crate::handlers::{ AnalyzeBookHandler, AnalyzeSeriesHandler, BackfillTrackingFromMetadataHandler, BulkTrackForReleasesHandler, CleanupBookFilesHandler, CleanupOrphanedFilesHandler, CleanupPdfCacheHandler, CleanupPluginDataHandler, CleanupRefreshTokensHandler, @@ -46,14 +46,14 @@ struct InFlightGuard; impl InFlightGuard { fn new() -> Self { - crate::observability::metrics::task_in_flight_inc(); + codex_services::metrics::task_in_flight_inc(); Self } } impl Drop for InFlightGuard { fn drop(&mut self) { - crate::observability::metrics::task_in_flight_dec(); + codex_services::metrics::task_in_flight_dec(); } } @@ -808,7 +808,7 @@ impl TaskWorker { async fn complete_task( &self, task: &codex_db::entities::tasks::Model, - task_result: crate::tasks::types::TaskResult, + task_result: crate::types::TaskResult, started_at: chrono::DateTime<Utc>, recorded_events: Option<Vec<RecordedEvent>>, ) -> Result<()> { @@ -1015,8 +1015,8 @@ impl TaskWorker { #[cfg(test)] mod tests { use super::*; - use crate::tasks::handlers::TaskHandler; - use crate::tasks::types::{TaskResult, TaskType}; + use crate::handlers::TaskHandler; + use crate::types::{TaskResult, TaskType}; use codex_db::repositories::TaskRepository; use codex_db::test_helpers::create_test_db; use codex_events::{EntityChangeEvent, EntityEvent, EntityType}; diff --git a/src/api/docs.rs b/src/api/docs.rs index 80c31976..3c76f5c1 100644 --- a/src/api/docs.rs +++ b/src/api/docs.rs @@ -1002,9 +1002,9 @@ The following paths are exempt from rate limiting: v1::handlers::task_queue::GenerateBookThumbnailsRequest, v1::handlers::task_queue::GenerateSeriesThumbnailsRequest, v1::handlers::task_queue::ForceRequest, - crate::tasks::types::TaskStats, - crate::tasks::types::TaskTypeStats, - crate::tasks::types::TaskType, + codex_tasks::types::TaskStats, + codex_tasks::types::TaskTypeStats, + codex_tasks::types::TaskType, // Duplicates DTOs v1::dto::DuplicateGroup, diff --git a/src/api/routes/v1/handlers/books.rs b/src/api/routes/v1/handlers/books.rs index fb71a7bb..a242f05b 100644 --- a/src/api/routes/v1/handlers/books.rs +++ b/src/api/routes/v1/handlers/books.rs @@ -4299,9 +4299,9 @@ use super::super::dto::{ BookErrorDto, BookErrorTypeDto, BookWithErrorsDto, BooksWithErrorsResponse, ErrorGroupDto, RetryAllErrorsRequest, RetryBookErrorsRequest, RetryErrorsResponse, }; -use crate::tasks::types::TaskType; use codex_db::entities::book_error::{BookErrorType, parse_analysis_errors}; use codex_db::repositories::TaskRepository; +use codex_tasks::types::TaskType; /// Query parameters for listing books with analysis errors #[derive(Debug, Deserialize, utoipa::IntoParams)] diff --git a/src/api/routes/v1/handlers/bulk.rs b/src/api/routes/v1/handlers/bulk.rs index 5fba8048..e9756e1e 100644 --- a/src/api/routes/v1/handlers/bulk.rs +++ b/src/api/routes/v1/handlers/bulk.rs @@ -11,7 +11,6 @@ use super::super::dto::{ }; use crate::api::{AppState, error::ApiError, extractors::AuthContext, permissions::Permission}; use crate::require_permission; -use crate::tasks::types::TaskType; use axum::{Json, extract::State}; use chrono::Utc; use codex_db::repositories::{ @@ -21,6 +20,7 @@ use codex_db::repositories::{ TaskRepository, }; use codex_events::{EntityChangeEvent, EntityEvent}; +use codex_tasks::types::TaskType; use std::sync::Arc; use uuid::Uuid; diff --git a/src/api/routes/v1/handlers/cleanup.rs b/src/api/routes/v1/handlers/cleanup.rs index 7e318381..5018f648 100644 --- a/src/api/routes/v1/handlers/cleanup.rs +++ b/src/api/routes/v1/handlers/cleanup.rs @@ -18,9 +18,9 @@ use crate::api::{ permissions::Permission, }; use crate::require_permission; -use crate::tasks::types::TaskType; use codex_db::repositories::{BookRepository, SeriesRepository, TaskRepository}; use codex_services::file_cleanup::OrphanedFileType; +use codex_tasks::types::TaskType; /// Get statistics about orphaned files /// diff --git a/src/api/routes/v1/handlers/duplicates.rs b/src/api/routes/v1/handlers/duplicates.rs index 8e04a355..03170fcc 100644 --- a/src/api/routes/v1/handlers/duplicates.rs +++ b/src/api/routes/v1/handlers/duplicates.rs @@ -17,11 +17,11 @@ use super::super::dto::{ TriggerDuplicateScanResponse, }; use crate::api::{AppState, error::ApiError, extractors::AuthContext, permissions::Permission}; -use crate::tasks::types::TaskType; use codex_db::entities::series_duplicates::{MATCH_TYPE_EXTERNAL_ID, MATCH_TYPE_TITLE}; use codex_db::repositories::{ BookDuplicatesRepository, SeriesDuplicatesRepository, TaskRepository, }; +use codex_tasks::types::TaskType; /// List all duplicate book groups /// diff --git a/src/api/routes/v1/handlers/libraries.rs b/src/api/routes/v1/handlers/libraries.rs index 6bfd96cd..aaa4cc1e 100644 --- a/src/api/routes/v1/handlers/libraries.rs +++ b/src/api/routes/v1/handlers/libraries.rs @@ -304,7 +304,7 @@ pub async fn create_library( // Trigger scan immediately after creation if requested if request.scan_immediately { - let task_type = crate::tasks::types::TaskType::ScanLibrary { + let task_type = codex_tasks::types::TaskType::ScanLibrary { library_id: library.id, mode: "normal".to_string(), }; diff --git a/src/api/routes/v1/handlers/library_jobs.rs b/src/api/routes/v1/handlers/library_jobs.rs index 10d74883..4ce8cddf 100644 --- a/src/api/routes/v1/handlers/library_jobs.rs +++ b/src/api/routes/v1/handlers/library_jobs.rs @@ -15,7 +15,6 @@ use crate::api::{ permissions::Permission, }; use crate::require_permission; -use crate::tasks::types::TaskType; use codex_db::entities::library_jobs; use codex_db::repositories::{ CreateLibraryJobParams, LibraryJobRepository, LibraryRepository, SeriesRepository, @@ -24,6 +23,7 @@ use codex_services::library_jobs::{ LibraryJobConfig, MetadataRefreshJobConfig, parse_job_config, validation, }; use codex_services::metadata::{FieldGroup, RefreshPlanner, fields_for_group}; +use codex_tasks::types::TaskType; use super::super::dto::patch::PatchValue; use super::super::dto::{ diff --git a/src/api/routes/v1/handlers/pdf_cache.rs b/src/api/routes/v1/handlers/pdf_cache.rs index 55b07c42..77bc606f 100644 --- a/src/api/routes/v1/handlers/pdf_cache.rs +++ b/src/api/routes/v1/handlers/pdf_cache.rs @@ -21,8 +21,8 @@ use crate::api::{ permissions::Permission, }; use crate::require_permission; -use crate::tasks::types::TaskType; use codex_db::repositories::TaskRepository; +use codex_tasks::types::TaskType; /// Build the page-cache stats DTO from the current AppState. async fn page_cache_stats(state: &AppState) -> Result<PdfPageCacheStatsDto, ApiError> { diff --git a/src/api/routes/v1/handlers/plugin_actions.rs b/src/api/routes/v1/handlers/plugin_actions.rs index c58910d9..c6855f79 100644 --- a/src/api/routes/v1/handlers/plugin_actions.rs +++ b/src/api/routes/v1/handlers/plugin_actions.rs @@ -20,7 +20,6 @@ use super::super::dto::{ PreviewSummary, SearchTitleResponse, SkippedField, parse_scope, }; use crate::api::{AppState, error::ApiError, extractors::AuthContext, permissions::Permission}; -use crate::tasks::types::TaskType; use axum::{ Json, extract::{Path, Query, State}, @@ -43,6 +42,7 @@ use codex_services::plugin::protocol::{ BookMatchParams, BookSearchParams, MetadataContentType, MetadataGetParams, MetadataMatchParams, MetadataSearchParams, PluginScope, }; +use codex_tasks::types::TaskType; use sea_orm::prelude::Decimal; use serde::Deserialize; use std::collections::{HashMap, HashSet}; diff --git a/src/api/routes/v1/handlers/recommendations.rs b/src/api/routes/v1/handlers/recommendations.rs index d52fe7e5..43f9a56b 100644 --- a/src/api/routes/v1/handlers/recommendations.rs +++ b/src/api/routes/v1/handlers/recommendations.rs @@ -10,7 +10,6 @@ use super::super::dto::recommendations::{ }; use crate::api::extractors::auth::AuthContext; use crate::api::{error::ApiError, extractors::AppState}; -use crate::tasks::types::TaskType; use axum::{ Json, extract::{Path, State}, @@ -22,6 +21,7 @@ use codex_db::repositories::{ }; use codex_services::plugin::protocol::PluginManifest; use codex_services::plugin::recommendations::RecommendationResponse; +use codex_tasks::types::TaskType; use std::sync::Arc; use tracing::{debug, info, warn}; use uuid::Uuid; diff --git a/src/api/routes/v1/handlers/scan.rs b/src/api/routes/v1/handlers/scan.rs index d8e4efb7..096444e1 100644 --- a/src/api/routes/v1/handlers/scan.rs +++ b/src/api/routes/v1/handlers/scan.rs @@ -14,9 +14,9 @@ use uuid::Uuid; use super::super::dto::{ScanStatusDto, TriggerScanQuery}; use super::task_queue::CreateTaskResponse; use crate::api::{AppState, error::ApiError, extractors::AuthContext, permissions::Permission}; -use crate::tasks::types::TaskType; use codex_db::repositories::{BookRepository, LibraryRepository, SeriesRepository, TaskRepository}; use codex_scanner::ScanMode; +use codex_tasks::types::TaskType; /// Trigger a library scan /// diff --git a/src/api/routes/v1/handlers/series.rs b/src/api/routes/v1/handlers/series.rs index a77760a7..11409366 100644 --- a/src/api/routes/v1/handlers/series.rs +++ b/src/api/routes/v1/handlers/series.rs @@ -1981,8 +1981,8 @@ pub async fn get_series_thumbnail( ); // Queue the thumbnail generation task (fire and forget) - use crate::tasks::types::TaskType; use codex_db::repositories::TaskRepository; + use codex_tasks::types::TaskType; let task_type = TaskType::GenerateSeriesThumbnail { series_id, @@ -5885,8 +5885,8 @@ pub async fn get_series_cover_image( /// This should be called whenever a series cover is selected/unselected to ensure /// the cached thumbnail reflects the current cover selection. async fn regenerate_series_thumbnail(state: &AuthState, series_id: Uuid) { - use crate::tasks::types::TaskType; use codex_db::repositories::TaskRepository; + use codex_tasks::types::TaskType; // Delete the cached series thumbnail first if let Err(e) = state diff --git a/src/api/routes/v1/handlers/series_exports.rs b/src/api/routes/v1/handlers/series_exports.rs index ea760368..b4a94023 100644 --- a/src/api/routes/v1/handlers/series_exports.rs +++ b/src/api/routes/v1/handlers/series_exports.rs @@ -12,10 +12,10 @@ use uuid::Uuid; use crate::api::error::ApiError; use crate::api::extractors::auth::{AppState, AuthContext}; -use crate::tasks::types::TaskType; use codex_db::repositories::{SeriesExportRepository, TaskRepository}; use codex_services::book_export_collector::BookExportField; use codex_services::series_export_collector::ExportField; +use codex_tasks::types::TaskType; use super::super::dto::series_export::{ CreateSeriesExportRequest, ExportFieldCatalogResponse, ExportFieldDto, ExportPresetsDto, diff --git a/src/api/routes/v1/handlers/task_queue.rs b/src/api/routes/v1/handlers/task_queue.rs index 8df29a67..cc347547 100644 --- a/src/api/routes/v1/handlers/task_queue.rs +++ b/src/api/routes/v1/handlers/task_queue.rs @@ -10,10 +10,10 @@ use uuid::Uuid; use crate::api::{error::ApiError, extractors::AuthContext, permissions::Permission}; use crate::require_permission; -use crate::tasks::types::{TaskStats, TaskType}; use codex_db::repositories::{ LibraryRepository, SeriesMetadataRepository, SeriesRepository, TaskRepository, }; +use codex_tasks::types::{TaskStats, TaskType}; use super::super::dto::series::{ EnqueueReprocessTitleRequest, EnqueueReprocessTitleResponse, ReprocessSeriesTitlesRequest, diff --git a/src/api/routes/v1/handlers/user_plugins.rs b/src/api/routes/v1/handlers/user_plugins.rs index f770a0b4..7a560d39 100644 --- a/src/api/routes/v1/handlers/user_plugins.rs +++ b/src/api/routes/v1/handlers/user_plugins.rs @@ -13,8 +13,6 @@ use super::super::dto::user_plugins::{ }; use crate::api::extractors::auth::AuthContext; use crate::api::{error::ApiError, extractors::AppState}; -use crate::tasks::handlers::user_plugin_sync::LAST_SYNC_RESULT_KEY; -use crate::tasks::types::TaskType; use axum::{ Json, extract::{Path, Query, State}, @@ -25,6 +23,8 @@ use codex_db::repositories::{ }; use codex_services::plugin::protocol::{OAuthConfig, PluginManifest, methods}; use codex_services::plugin::sync::SyncStatusResponse; +use codex_tasks::handlers::user_plugin_sync::LAST_SYNC_RESULT_KEY; +use codex_tasks::types::TaskType; use std::sync::Arc; use tracing::{debug, info, warn}; use uuid::Uuid; diff --git a/src/commands/common.rs b/src/commands/common.rs index 54b98b36..d911ad7e 100644 --- a/src/commands/common.rs +++ b/src/commands/common.rs @@ -1,9 +1,9 @@ use crate::observability::ObservabilityHandle; -use crate::tasks::TaskWorker; use codex_config::{Config, DatabaseConfig, DatabaseType, EnvOverride}; use codex_db::Database; use codex_events::EventBroadcaster; use codex_services::{SettingsService, TaskMetricsService}; +use codex_tasks::TaskWorker; use sea_orm::DatabaseConnection; use std::fs; use std::path::{Path, PathBuf}; diff --git a/src/commands/serve.rs b/src/commands/serve.rs index 179031bc..86a0969c 100644 --- a/src/commands/serve.rs +++ b/src/commands/serve.rs @@ -395,7 +395,7 @@ pub async fn serve_command(config_path: PathBuf) -> anyhow::Result<()> { } // Reconcile orphaned series exports from prior crash/restart - if let Err(e) = crate::tasks::handlers::cleanup_series_exports::reconcile_on_startup( + if let Err(e) = codex_tasks::handlers::cleanup_series_exports::reconcile_on_startup( db.sea_orm_connection(), ) .await diff --git a/src/lib.rs b/src/lib.rs index 99a85cee..a3314d5f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,7 +1,6 @@ pub mod api; pub mod observability; pub mod scheduler; -pub mod tasks; pub mod web; // Re-exports of workspace-leaf crates so existing `codex::config::*`, @@ -16,4 +15,5 @@ pub use codex_parsers as parsers; pub use codex_scanner as scanner; pub use codex_search as search; pub use codex_services as services; +pub use codex_tasks as tasks; pub use codex_utils as utils; diff --git a/src/main.rs b/src/main.rs index c84cd11b..88940bbb 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,7 +2,6 @@ mod api; mod commands; mod observability; mod scheduler; -mod tasks; mod web; use clap::{Parser, Subcommand}; diff --git a/src/scheduler/mod.rs b/src/scheduler/mod.rs index bdc00bb2..f590bb7e 100644 --- a/src/scheduler/mod.rs +++ b/src/scheduler/mod.rs @@ -7,12 +7,12 @@ use tokio_cron_scheduler::{Job, JobScheduler}; use tracing::{debug, error, info, warn}; use uuid::Uuid; -use crate::tasks::types::TaskType; use codex_db::entities::library_jobs; use codex_db::repositories::{LibraryJobRepository, LibraryRepository, TaskRepository}; use codex_scanner::{ScanMode, ScanningConfig}; use codex_services::library_jobs::{LibraryJobConfig, parse_job_config}; use codex_services::settings::SettingsService; +use codex_tasks::types::TaskType; use codex_utils::cron::{normalize_cron_expression, parse_timezone}; /// Generic scheduler for managing scheduled tasks (library scans, deduplication, etc.) @@ -807,10 +807,10 @@ pub async fn has_active_refresh_for_job(db: &DatabaseConnection, job_id: Uuid) - #[cfg(test)] mod tests { use super::*; - use crate::tasks::types::TaskType; use codex_db::repositories::LibraryRepository; use codex_db::test_helpers::setup_test_db; use codex_models::ScanningStrategy; + use codex_tasks::types::TaskType; #[test] fn test_scheduler_can_be_created() { diff --git a/src/scheduler/release_sources.rs b/src/scheduler/release_sources.rs index 123afb88..9ccc9c65 100644 --- a/src/scheduler/release_sources.rs +++ b/src/scheduler/release_sources.rs @@ -23,10 +23,10 @@ use tokio_cron_scheduler::{Job, JobScheduler}; use tracing::{debug, error, info, warn}; use uuid::Uuid; -use crate::tasks::types::TaskType; use codex_db::repositories::{ReleaseSourceRepository, TaskRepository}; use codex_services::release::schedule::{read_default_cron_schedule, resolve_cron_schedule}; use codex_services::settings::SettingsService; +use codex_tasks::types::TaskType; use codex_utils::cron::normalize_cron_expression; /// Tracks scheduler-registered jobs per source row so we can reconcile. From 9dd1d54870b8014832bc577357ee56d35949a151 Mon Sep 17 00:00:00 2001 From: Sylvain Cau <ashdevfr@gmail.com> Date: Sat, 23 May 2026 20:02:13 -0700 Subject: [PATCH 09/14] refactor(workspace): extract codex-scheduler crate Moves the cron-driven scheduler (tokio-cron-scheduler integration, library scan job dispatch, release-source poll scheduling) out of the root crate into the final business-layer sibling workspace member. Top of the business-layer stack: depends on codex-services (settings service, release schedule resolver, library job parser), codex-scanner (ScanMode, ScanningConfig for the scan job factory), codex-tasks (TaskType enqueuing), plus codex-db and codex-models for repository access and shared types. The SharedSchedulerReconciler trait the plugin manager uses lives in codex-services::scheduler_handle; the scheduler crate provides the concrete impl that the binary wires up at serve time, so the cycle stays broken by trait. Root crate keeps `pub use codex_scheduler as scheduler` so callers via codex::scheduler::* continue to resolve. With this extraction, the root crate now contains only api, observability, web, commands, main, and the re-export facade. --- Cargo.lock | 24 +++++++++++ Cargo.toml | 3 ++ crates/codex-scheduler/Cargo.toml | 40 +++++++++++++++++++ .../codex-scheduler/src/lib.rs | 0 .../codex-scheduler/src}/release_sources.rs | 0 src/api/extractors/auth.rs | 2 +- src/api/routes/v1/handlers/library_jobs.rs | 2 +- src/api/routes/v1/handlers/releases.rs | 4 +- src/commands/serve.rs | 6 +-- src/lib.rs | 2 +- src/main.rs | 1 - 11 files changed, 75 insertions(+), 9 deletions(-) create mode 100644 crates/codex-scheduler/Cargo.toml rename src/scheduler/mod.rs => crates/codex-scheduler/src/lib.rs (100%) rename {src/scheduler => crates/codex-scheduler/src}/release_sources.rs (100%) diff --git a/Cargo.lock b/Cargo.lock index e3285a78..39108f2b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -827,6 +827,7 @@ dependencies = [ "codex-models", "codex-parsers", "codex-scanner", + "codex-scheduler", "codex-search", "codex-services", "codex-tasks", @@ -1011,6 +1012,29 @@ dependencies = [ "zip", ] +[[package]] +name = "codex-scheduler" +version = "0.0.0" +dependencies = [ + "anyhow", + "chrono", + "chrono-tz", + "codex-db", + "codex-models", + "codex-scanner", + "codex-services", + "codex-tasks", + "codex-utils", + "futures", + "sea-orm", + "serde_json", + "tempfile", + "tokio", + "tokio-cron-scheduler", + "tracing", + "uuid", +] + [[package]] name = "codex-search" version = "0.0.0" diff --git a/Cargo.toml b/Cargo.toml index 0c5c0266..8bf6cae9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -42,6 +42,7 @@ members = [ "crates/codex-search", "crates/codex-scanner", "crates/codex-tasks", + "crates/codex-scheduler", ] # Shared dependencies inherited by workspace members. Only deps that are @@ -71,6 +72,7 @@ codex-models = { path = "crates/codex-models" } codex-parsers = { path = "crates/codex-parsers", default-features = false } codex-scanner = { path = "crates/codex-scanner", default-features = false } codex-search = { path = "crates/codex-search" } +codex-scheduler = { path = "crates/codex-scheduler" } codex-services = { path = "crates/codex-services" } codex-tasks = { path = "crates/codex-tasks", default-features = false } codex-utils = { path = "crates/codex-utils" } @@ -157,6 +159,7 @@ codex-models = { workspace = true } codex-parsers = { workspace = true } codex-scanner = { workspace = true } codex-search = { workspace = true } +codex-scheduler = { workspace = true } codex-services = { workspace = true } codex-tasks = { workspace = true } codex-utils = { workspace = true } diff --git a/crates/codex-scheduler/Cargo.toml b/crates/codex-scheduler/Cargo.toml new file mode 100644 index 00000000..a848a73d --- /dev/null +++ b/crates/codex-scheduler/Cargo.toml @@ -0,0 +1,40 @@ +[package] +name = "codex-scheduler" +version = "0.0.0" +edition = "2024" +publish = false + +[lib] +name = "codex_scheduler" +path = "src/lib.rs" + +[dependencies] +anyhow = { workspace = true } +chrono = { workspace = true } +tokio = { workspace = true } +tracing = { workspace = true } +uuid = { workspace = true } + +codex-db = { workspace = true } +codex-models = { workspace = true } +codex-scanner = { workspace = true } +codex-services = { workspace = true } +codex-tasks = { workspace = true } +codex-utils = { workspace = true } + +chrono-tz = "0.10" +futures = "0.3" +serde_json = "1.0" +sea-orm = { version = "1.1", features = [ + "sqlx-postgres", + "sqlx-sqlite", + "runtime-tokio-rustls", + "macros", + "with-chrono", + "with-uuid", +] } +tokio-cron-scheduler = "0.15" + +[dev-dependencies] +tempfile = { workspace = true } +codex-db = { workspace = true, features = ["test-utils"] } diff --git a/src/scheduler/mod.rs b/crates/codex-scheduler/src/lib.rs similarity index 100% rename from src/scheduler/mod.rs rename to crates/codex-scheduler/src/lib.rs diff --git a/src/scheduler/release_sources.rs b/crates/codex-scheduler/src/release_sources.rs similarity index 100% rename from src/scheduler/release_sources.rs rename to crates/codex-scheduler/src/release_sources.rs diff --git a/src/api/extractors/auth.rs b/src/api/extractors/auth.rs index b843da9c..179e4c26 100644 --- a/src/api/extractors/auth.rs +++ b/src/api/extractors/auth.rs @@ -197,7 +197,7 @@ pub struct AppState { pub task_metrics_service: Option<Arc<codex_services::TaskMetricsService>>, /// Scheduler for managing scheduled tasks (library scans, deduplication, etc.) /// None when workers are disabled (CODEX_DISABLE_WORKERS=true) or in test environments - pub scheduler: Option<Arc<tokio::sync::Mutex<crate::scheduler::Scheduler>>>, + pub scheduler: Option<Arc<tokio::sync::Mutex<codex_scheduler::Scheduler>>>, /// Read progress batching service for efficient page view tracking /// Batches progress updates in memory and flushes periodically to reduce DB load pub read_progress_service: Arc<codex_services::ReadProgressService>, diff --git a/src/api/routes/v1/handlers/library_jobs.rs b/src/api/routes/v1/handlers/library_jobs.rs index 4ce8cddf..04e7b018 100644 --- a/src/api/routes/v1/handlers/library_jobs.rs +++ b/src/api/routes/v1/handlers/library_jobs.rs @@ -323,7 +323,7 @@ pub async fn run_job_now( return Err(ApiError::NotFound("Job not found".to_string())); } - if crate::scheduler::has_active_refresh_for_job(&state.db, job_id) + if codex_scheduler::has_active_refresh_for_job(&state.db, job_id) .await .map_err(|e| anyhow_to_api_error(e, "Failed to check in-flight task"))? { diff --git a/src/api/routes/v1/handlers/releases.rs b/src/api/routes/v1/handlers/releases.rs index e8df628c..a8184667 100644 --- a/src/api/routes/v1/handlers/releases.rs +++ b/src/api/routes/v1/handlers/releases.rs @@ -1016,7 +1016,7 @@ pub async fn poll_release_source_now( ))); } - let outcome = crate::scheduler::release_sources::enqueue_poll_now(&state.db, source_id) + let outcome = codex_scheduler::release_sources::enqueue_poll_now(&state.db, source_id) .await .map_err(|e| ApiError::Internal(format!("Failed to enqueue poll task: {}", e)))?; @@ -1076,7 +1076,7 @@ pub async fn poll_release_sources_now_all( let mut coalesced = 0usize; let mut failed = 0usize; for source in sources { - match crate::scheduler::release_sources::enqueue_poll_now(&state.db, source.id).await { + match codex_scheduler::release_sources::enqueue_poll_now(&state.db, source.id).await { Ok(outcome) => { if outcome.coalesced { coalesced += 1; diff --git a/src/commands/serve.rs b/src/commands/serve.rs index 86a0969c..d8625588 100644 --- a/src/commands/serve.rs +++ b/src/commands/serve.rs @@ -108,9 +108,9 @@ pub async fn serve_command(config_path: PathBuf) -> anyhow::Result<()> { // Create and start scheduler info!("Initializing job scheduler..."); - let scheduler: Arc<tokio::sync::Mutex<crate::scheduler::Scheduler>> = + let scheduler: Arc<tokio::sync::Mutex<codex_scheduler::Scheduler>> = Arc::new(tokio::sync::Mutex::new( - crate::scheduler::Scheduler::new( + codex_scheduler::Scheduler::new( db.sea_orm_connection().clone(), &config.scheduler.timezone, ) @@ -342,7 +342,7 @@ pub async fn serve_command(config_path: PathBuf) -> anyhow::Result<()> { // Wrap the scheduler in the services-layer trait so plugin handles can // trigger reconciles without holding the concrete scheduler type. let scheduler_handle: codex_services::scheduler_handle::SharedSchedulerReconciler = Arc::new( - crate::scheduler::LockedSchedulerReconciler::new(scheduler.clone()), + codex_scheduler::LockedSchedulerReconciler::new(scheduler.clone()), ); let plugin_manager = Arc::new( codex_services::plugin::PluginManager::with_defaults(Arc::new( diff --git a/src/lib.rs b/src/lib.rs index a3314d5f..2a2db781 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,6 +1,5 @@ pub mod api; pub mod observability; -pub mod scheduler; pub mod web; // Re-exports of workspace-leaf crates so existing `codex::config::*`, @@ -13,6 +12,7 @@ pub use codex_events as events; pub use codex_models as models; pub use codex_parsers as parsers; pub use codex_scanner as scanner; +pub use codex_scheduler as scheduler; pub use codex_search as search; pub use codex_services as services; pub use codex_tasks as tasks; diff --git a/src/main.rs b/src/main.rs index 88940bbb..d6ed6f6f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,7 +1,6 @@ mod api; mod commands; mod observability; -mod scheduler; mod web; use clap::{Parser, Subcommand}; From 91373567f581ac93d861ba8ad33fcd03f68a129e Mon Sep 17 00:00:00 2001 From: Sylvain Cau <ashdevfr@gmail.com> Date: Sat, 23 May 2026 21:05:54 -0700 Subject: [PATCH 10/14] refactor(workspace): extract codex-api crate, slim root to binary glue Move src/api/, src/web.rs, and src/observability/ into a new crates/codex-api/ workspace member. The root codex crate now contains only main.rs, commands/ (CLI orchestration), and lib.rs re-exports that keep the historic codex::api / codex::observability / codex::web paths working for integration tests. Root [dependencies] drops from ~50 inline crates to ~14: workspace members + the few helpers commands/ actually uses (clap, sea-orm, rand, tabled, tracing-subscriber, tracing-appender, axum::serve, walkdir). The `rar`, `observability`, and `embed-frontend` features now cascade through codex-api. Two version-propagation issues surfaced and are fixed here: - `info::get_app_info` now reads app_name/app_version from AppState (env!("CARGO_PKG_VERSION") inside codex-api resolves to 0.0.0). The binary populates these from its own env vars; tests do the same. - The OpenAPI spec version is wired via a crates/codex-api/build.rs that reads the root Cargo.toml and emits CODEX_BIN_VERSION as a build-time env var picked up by the utoipa::OpenApi derive. Touching an api handler now recompiles only codex-api and the root binary; touching src/commands/*.rs recompiles only the binary. Cold build and warm-rebuild times both drop materially against the pre-split baseline. Workspace builds clean, clippy --workspace --all-targets is warning- free, cargo dist plan unchanged, make openapi produces the correct 1.29.0 spec, and the full test suite passes. --- Cargo.lock | 81 ++++--- Cargo.toml | 229 ++++++------------ crates/codex-api/Cargo.toml | 157 ++++++++++++ crates/codex-api/build.rs | 34 +++ {src/api => crates/codex-api/src}/docs.rs | 7 +- {src/api => crates/codex-api/src}/error.rs | 0 .../codex-api/src}/extractors/auth.rs | 11 +- .../codex-api/src}/extractors/client_info.rs | 0 .../codex-api/src}/extractors/mod.rs | 0 src/api/mod.rs => crates/codex-api/src/lib.rs | 2 + .../codex-api/src}/middleware/auth.rs | 0 .../codex-api/src}/middleware/http_metrics.rs | 0 .../codex-api/src}/middleware/mod.rs | 0 .../codex-api/src}/middleware/permissions.rs | 0 .../codex-api/src}/middleware/rate_limit.rs | 0 .../codex-api/src}/middleware/tracing.rs | 0 .../codex-api/src}/observability/http.rs | 0 .../codex-api/src}/observability/inventory.rs | 0 .../codex-api/src}/observability/mod.rs | 0 .../codex-api/src}/observability/providers.rs | 0 .../codex-api/src}/observability/stub.rs | 0 .../codex-api/src}/observability/trace_fmt.rs | 0 .../codex-api/src}/permissions.rs | 0 .../codex-api/src}/routes/komga/dto/book.rs | 0 .../src}/routes/komga/dto/library.rs | 0 .../src}/routes/komga/dto/manifest.rs | 0 .../codex-api/src}/routes/komga/dto/mod.rs | 0 .../codex-api/src}/routes/komga/dto/page.rs | 0 .../src}/routes/komga/dto/pagination.rs | 0 .../codex-api/src}/routes/komga/dto/series.rs | 0 .../codex-api/src}/routes/komga/dto/stubs.rs | 0 .../codex-api/src}/routes/komga/dto/user.rs | 0 .../src}/routes/komga/handlers/books.rs | 4 +- .../src}/routes/komga/handlers/libraries.rs | 4 +- .../src}/routes/komga/handlers/manifest.rs | 4 +- .../src}/routes/komga/handlers/mod.rs | 0 .../src}/routes/komga/handlers/pages.rs | 4 +- .../routes/komga/handlers/read_progress.rs | 6 +- .../src}/routes/komga/handlers/series.rs | 4 +- .../src}/routes/komga/handlers/stubs.rs | 4 +- .../src}/routes/komga/handlers/users.rs | 6 +- .../codex-api/src}/routes/komga/mod.rs | 2 +- .../src}/routes/komga/routes/books.rs | 2 +- .../src}/routes/komga/routes/libraries.rs | 2 +- .../codex-api/src}/routes/komga/routes/mod.rs | 2 +- .../src}/routes/komga/routes/pages.rs | 2 +- .../src}/routes/komga/routes/read_progress.rs | 2 +- .../src}/routes/komga/routes/series.rs | 2 +- .../src}/routes/komga/routes/stubs.rs | 2 +- .../src}/routes/komga/routes/users.rs | 2 +- .../codex-api/src}/routes/koreader/dto/mod.rs | 0 .../src}/routes/koreader/dto/progress.rs | 0 .../src}/routes/koreader/handlers/auth.rs | 6 +- .../src}/routes/koreader/handlers/mod.rs | 0 .../src}/routes/koreader/handlers/sync.rs | 8 +- .../codex-api/src}/routes/koreader/mod.rs | 2 +- .../src}/routes/koreader/routes/mod.rs | 4 +- .../codex-api/src}/routes/mod.rs | 8 +- .../codex-api/src}/routes/opds/dto/entry.rs | 0 .../codex-api/src}/routes/opds/dto/feed.rs | 0 .../codex-api/src}/routes/opds/dto/link.rs | 0 .../codex-api/src}/routes/opds/dto/mod.rs | 0 .../src}/routes/opds/handlers/catalog.rs | 4 +- .../src}/routes/opds/handlers/mod.rs | 0 .../src}/routes/opds/handlers/pse.rs | 6 +- .../src}/routes/opds/handlers/search.rs | 4 +- .../codex-api/src}/routes/opds/mod.rs | 2 +- .../codex-api/src}/routes/opds/routes.rs | 2 +- .../codex-api/src}/routes/opds2/dto/feed.rs | 2 +- .../codex-api/src}/routes/opds2/dto/link.rs | 0 .../src}/routes/opds2/dto/metadata.rs | 0 .../codex-api/src}/routes/opds2/dto/mod.rs | 0 .../src}/routes/opds2/dto/publication.rs | 2 +- .../src}/routes/opds2/handlers/catalog.rs | 4 +- .../src}/routes/opds2/handlers/mod.rs | 0 .../src}/routes/opds2/handlers/search.rs | 4 +- .../codex-api/src}/routes/opds2/mod.rs | 2 +- .../codex-api/src}/routes/opds2/routes.rs | 2 +- .../codex-api/src}/routes/v1/dto/api_key.rs | 0 .../codex-api/src}/routes/v1/dto/auth.rs | 0 .../codex-api/src}/routes/v1/dto/book.rs | 0 .../src}/routes/v1/dto/bulk_metadata.rs | 0 .../codex-api/src}/routes/v1/dto/cleanup.rs | 0 .../codex-api/src}/routes/v1/dto/common.rs | 0 .../src}/routes/v1/dto/duplicates.rs | 0 .../codex-api/src}/routes/v1/dto/filter.rs | 0 .../src}/routes/v1/dto/filter_preset.rs | 0 .../codex-api/src}/routes/v1/dto/info.rs | 0 .../codex-api/src}/routes/v1/dto/library.rs | 0 .../src}/routes/v1/dto/library_jobs.rs | 2 +- .../codex-api/src}/routes/v1/dto/metrics.rs | 0 .../codex-api/src}/routes/v1/dto/mod.rs | 0 .../src}/routes/v1/dto/observability.rs | 0 .../codex-api/src}/routes/v1/dto/oidc.rs | 0 .../codex-api/src}/routes/v1/dto/page.rs | 0 .../codex-api/src}/routes/v1/dto/patch.rs | 0 .../codex-api/src}/routes/v1/dto/pdf_cache.rs | 0 .../src}/routes/v1/dto/plugin_storage.rs | 0 .../codex-api/src}/routes/v1/dto/plugins.rs | 0 .../src}/routes/v1/dto/read_progress.rs | 0 .../src}/routes/v1/dto/recommendations.rs | 0 .../codex-api/src}/routes/v1/dto/release.rs | 0 .../codex-api/src}/routes/v1/dto/scan.rs | 0 .../codex-api/src}/routes/v1/dto/series.rs | 0 .../src}/routes/v1/dto/series_export.rs | 0 .../codex-api/src}/routes/v1/dto/settings.rs | 0 .../codex-api/src}/routes/v1/dto/setup.rs | 0 .../src}/routes/v1/dto/sharing_tag.rs | 0 .../src}/routes/v1/dto/task_metrics.rs | 0 .../codex-api/src}/routes/v1/dto/tracking.rs | 0 .../codex-api/src}/routes/v1/dto/user.rs | 2 +- .../src}/routes/v1/dto/user_plugins.rs | 0 .../src}/routes/v1/dto/user_preferences.rs | 0 .../src}/routes/v1/handlers/api_keys.rs | 2 +- .../codex-api/src}/routes/v1/handlers/auth.rs | 2 +- .../src}/routes/v1/handlers/books.rs | 26 +- .../codex-api/src}/routes/v1/handlers/bulk.rs | 2 +- .../src}/routes/v1/handlers/bulk_metadata.rs | 2 +- .../src}/routes/v1/handlers/cleanup.rs | 4 +- .../src}/routes/v1/handlers/duplicates.rs | 2 +- .../src}/routes/v1/handlers/events.rs | 2 +- .../src}/routes/v1/handlers/filesystem.rs | 6 +- .../src}/routes/v1/handlers/filter_presets.rs | 4 +- .../src}/routes/v1/handlers/health.rs | 0 .../codex-api/src}/routes/v1/handlers/info.rs | 11 +- .../src}/routes/v1/handlers/libraries.rs | 4 +- .../src}/routes/v1/handlers/library_jobs.rs | 4 +- .../src}/routes/v1/handlers/metrics.rs | 2 +- .../codex-api/src}/routes/v1/handlers/mod.rs | 0 .../src}/routes/v1/handlers/observability.rs | 2 +- .../codex-api/src}/routes/v1/handlers/oidc.rs | 2 +- .../src}/routes/v1/handlers/pages.rs | 6 +- .../src}/routes/v1/handlers/pdf_cache.rs | 4 +- .../src}/routes/v1/handlers/plugin_actions.rs | 2 +- .../src}/routes/v1/handlers/plugin_storage.rs | 4 +- .../src}/routes/v1/handlers/plugins.rs | 2 +- .../src}/routes/v1/handlers/read_progress.rs | 2 +- .../routes/v1/handlers/recommendations.rs | 6 +- .../src}/routes/v1/handlers/releases.rs | 2 +- .../codex-api/src}/routes/v1/handlers/scan.rs | 2 +- .../src}/routes/v1/handlers/series.rs | 8 +- .../src}/routes/v1/handlers/series_exports.rs | 4 +- .../src}/routes/v1/handlers/settings.rs | 10 +- .../src}/routes/v1/handlers/setup.rs | 6 +- .../src}/routes/v1/handlers/sharing_tags.rs | 2 +- .../src}/routes/v1/handlers/task_metrics.rs | 2 +- .../src}/routes/v1/handlers/task_queue.rs | 4 +- .../src}/routes/v1/handlers/tracking.rs | 4 +- .../src}/routes/v1/handlers/user_plugins.rs | 4 +- .../routes/v1/handlers/user_preferences.rs | 2 +- .../src}/routes/v1/handlers/users.rs | 4 +- .../codex-api/src}/routes/v1/mod.rs | 2 +- .../codex-api/src}/routes/v1/routes/admin.rs | 2 +- .../codex-api/src}/routes/v1/routes/auth.rs | 2 +- .../codex-api/src}/routes/v1/routes/books.rs | 2 +- .../src}/routes/v1/routes/libraries.rs | 2 +- .../codex-api/src}/routes/v1/routes/misc.rs | 2 +- .../codex-api/src}/routes/v1/routes/mod.rs | 2 +- .../src}/routes/v1/routes/observability.rs | 2 +- .../codex-api/src}/routes/v1/routes/oidc.rs | 2 +- .../src}/routes/v1/routes/plugins.rs | 2 +- .../src}/routes/v1/routes/recommendations.rs | 2 +- .../src}/routes/v1/routes/releases.rs | 2 +- .../codex-api/src}/routes/v1/routes/series.rs | 2 +- .../codex-api/src}/routes/v1/routes/setup.rs | 2 +- .../codex-api/src}/routes/v1/routes/tasks.rs | 2 +- .../codex-api/src}/routes/v1/routes/user.rs | 2 +- .../src}/routes/v1/routes/user_plugins.rs | 2 +- .../codex-api/src}/routes/v1/routes/users.rs | 2 +- {src => crates/codex-api/src}/web.rs | 0 docs/dev/contributing/development.md | 36 ++- src/commands/common.rs | 6 +- src/commands/openapi.rs | 2 +- src/commands/seed.rs | 12 +- src/commands/serve.rs | 10 +- src/lib.rs | 13 +- src/main.rs | 3 - tests/api/oidc.rs | 2 + tests/api/pdf_cache.rs | 2 + tests/api/rate_limit.rs | 2 + tests/api/refresh_token.rs | 2 + tests/api/task_metrics.rs | 2 + tests/common/http.rs | 6 + 183 files changed, 547 insertions(+), 385 deletions(-) create mode 100644 crates/codex-api/Cargo.toml create mode 100644 crates/codex-api/build.rs rename {src/api => crates/codex-api/src}/docs.rs (99%) rename {src/api => crates/codex-api/src}/error.rs (100%) rename {src/api => crates/codex-api/src}/extractors/auth.rs (98%) rename {src/api => crates/codex-api/src}/extractors/client_info.rs (100%) rename {src/api => crates/codex-api/src}/extractors/mod.rs (100%) rename src/api/mod.rs => crates/codex-api/src/lib.rs (85%) rename {src/api => crates/codex-api/src}/middleware/auth.rs (100%) rename {src/api => crates/codex-api/src}/middleware/http_metrics.rs (100%) rename {src/api => crates/codex-api/src}/middleware/mod.rs (100%) rename {src/api => crates/codex-api/src}/middleware/permissions.rs (100%) rename {src/api => crates/codex-api/src}/middleware/rate_limit.rs (100%) rename {src/api => crates/codex-api/src}/middleware/tracing.rs (100%) rename {src => crates/codex-api/src}/observability/http.rs (100%) rename {src => crates/codex-api/src}/observability/inventory.rs (100%) rename {src => crates/codex-api/src}/observability/mod.rs (100%) rename {src => crates/codex-api/src}/observability/providers.rs (100%) rename {src => crates/codex-api/src}/observability/stub.rs (100%) rename {src => crates/codex-api/src}/observability/trace_fmt.rs (100%) rename {src/api => crates/codex-api/src}/permissions.rs (100%) rename {src/api => crates/codex-api/src}/routes/komga/dto/book.rs (100%) rename {src/api => crates/codex-api/src}/routes/komga/dto/library.rs (100%) rename {src/api => crates/codex-api/src}/routes/komga/dto/manifest.rs (100%) rename {src/api => crates/codex-api/src}/routes/komga/dto/mod.rs (100%) rename {src/api => crates/codex-api/src}/routes/komga/dto/page.rs (100%) rename {src/api => crates/codex-api/src}/routes/komga/dto/pagination.rs (100%) rename {src/api => crates/codex-api/src}/routes/komga/dto/series.rs (100%) rename {src/api => crates/codex-api/src}/routes/komga/dto/stubs.rs (100%) rename {src/api => crates/codex-api/src}/routes/komga/dto/user.rs (100%) rename {src/api => crates/codex-api/src}/routes/komga/handlers/books.rs (99%) rename {src/api => crates/codex-api/src}/routes/komga/handlers/libraries.rs (99%) rename {src/api => crates/codex-api/src}/routes/komga/handlers/manifest.rs (99%) rename {src/api => crates/codex-api/src}/routes/komga/handlers/mod.rs (100%) rename {src/api => crates/codex-api/src}/routes/komga/handlers/pages.rs (99%) rename {src/api => crates/codex-api/src}/routes/komga/handlers/read_progress.rs (99%) rename {src/api => crates/codex-api/src}/routes/komga/handlers/series.rs (99%) rename {src/api => crates/codex-api/src}/routes/komga/handlers/stubs.rs (99%) rename {src/api => crates/codex-api/src}/routes/komga/handlers/users.rs (97%) rename {src/api => crates/codex-api/src}/routes/komga/mod.rs (98%) rename {src/api => crates/codex-api/src}/routes/komga/routes/books.rs (97%) rename {src/api => crates/codex-api/src}/routes/komga/routes/libraries.rs (95%) rename {src/api => crates/codex-api/src}/routes/komga/routes/mod.rs (98%) rename {src/api => crates/codex-api/src}/routes/komga/routes/pages.rs (96%) rename {src/api => crates/codex-api/src}/routes/komga/routes/read_progress.rs (97%) rename {src/api => crates/codex-api/src}/routes/komga/routes/series.rs (97%) rename {src/api => crates/codex-api/src}/routes/komga/routes/stubs.rs (97%) rename {src/api => crates/codex-api/src}/routes/komga/routes/users.rs (92%) rename {src/api => crates/codex-api/src}/routes/koreader/dto/mod.rs (100%) rename {src/api => crates/codex-api/src}/routes/koreader/dto/progress.rs (100%) rename {src/api => crates/codex-api/src}/routes/koreader/handlers/auth.rs (82%) rename {src/api => crates/codex-api/src}/routes/koreader/handlers/mod.rs (100%) rename {src/api => crates/codex-api/src}/routes/koreader/handlers/sync.rs (98%) rename {src/api => crates/codex-api/src}/routes/koreader/mod.rs (96%) rename {src/api => crates/codex-api/src}/routes/koreader/routes/mod.rs (91%) rename {src/api => crates/codex-api/src}/routes/mod.rs (97%) rename {src/api => crates/codex-api/src}/routes/opds/dto/entry.rs (100%) rename {src/api => crates/codex-api/src}/routes/opds/dto/feed.rs (100%) rename {src/api => crates/codex-api/src}/routes/opds/dto/link.rs (100%) rename {src/api => crates/codex-api/src}/routes/opds/dto/mod.rs (100%) rename {src/api => crates/codex-api/src}/routes/opds/handlers/catalog.rs (99%) rename {src/api => crates/codex-api/src}/routes/opds/handlers/mod.rs (100%) rename {src/api => crates/codex-api/src}/routes/opds/handlers/pse.rs (98%) rename {src/api => crates/codex-api/src}/routes/opds/handlers/search.rs (99%) rename {src/api => crates/codex-api/src}/routes/opds/mod.rs (96%) rename {src/api => crates/codex-api/src}/routes/opds/routes.rs (95%) rename {src/api => crates/codex-api/src}/routes/opds2/dto/feed.rs (99%) rename {src/api => crates/codex-api/src}/routes/opds2/dto/link.rs (100%) rename {src/api => crates/codex-api/src}/routes/opds2/dto/metadata.rs (100%) rename {src/api => crates/codex-api/src}/routes/opds2/dto/mod.rs (100%) rename {src/api => crates/codex-api/src}/routes/opds2/dto/publication.rs (99%) rename {src/api => crates/codex-api/src}/routes/opds2/handlers/catalog.rs (99%) rename {src/api => crates/codex-api/src}/routes/opds2/handlers/mod.rs (100%) rename {src/api => crates/codex-api/src}/routes/opds2/handlers/search.rs (99%) rename {src/api => crates/codex-api/src}/routes/opds2/mod.rs (96%) rename {src/api => crates/codex-api/src}/routes/opds2/routes.rs (94%) rename {src/api => crates/codex-api/src}/routes/v1/dto/api_key.rs (100%) rename {src/api => crates/codex-api/src}/routes/v1/dto/auth.rs (100%) rename {src/api => crates/codex-api/src}/routes/v1/dto/book.rs (100%) rename {src/api => crates/codex-api/src}/routes/v1/dto/bulk_metadata.rs (100%) rename {src/api => crates/codex-api/src}/routes/v1/dto/cleanup.rs (100%) rename {src/api => crates/codex-api/src}/routes/v1/dto/common.rs (100%) rename {src/api => crates/codex-api/src}/routes/v1/dto/duplicates.rs (100%) rename {src/api => crates/codex-api/src}/routes/v1/dto/filter.rs (100%) rename {src/api => crates/codex-api/src}/routes/v1/dto/filter_preset.rs (100%) rename {src/api => crates/codex-api/src}/routes/v1/dto/info.rs (100%) rename {src/api => crates/codex-api/src}/routes/v1/dto/library.rs (100%) rename {src/api => crates/codex-api/src}/routes/v1/dto/library_jobs.rs (99%) rename {src/api => crates/codex-api/src}/routes/v1/dto/metrics.rs (100%) rename {src/api => crates/codex-api/src}/routes/v1/dto/mod.rs (100%) rename {src/api => crates/codex-api/src}/routes/v1/dto/observability.rs (100%) rename {src/api => crates/codex-api/src}/routes/v1/dto/oidc.rs (100%) rename {src/api => crates/codex-api/src}/routes/v1/dto/page.rs (100%) rename {src/api => crates/codex-api/src}/routes/v1/dto/patch.rs (100%) rename {src/api => crates/codex-api/src}/routes/v1/dto/pdf_cache.rs (100%) rename {src/api => crates/codex-api/src}/routes/v1/dto/plugin_storage.rs (100%) rename {src/api => crates/codex-api/src}/routes/v1/dto/plugins.rs (100%) rename {src/api => crates/codex-api/src}/routes/v1/dto/read_progress.rs (100%) rename {src/api => crates/codex-api/src}/routes/v1/dto/recommendations.rs (100%) rename {src/api => crates/codex-api/src}/routes/v1/dto/release.rs (100%) rename {src/api => crates/codex-api/src}/routes/v1/dto/scan.rs (100%) rename {src/api => crates/codex-api/src}/routes/v1/dto/series.rs (100%) rename {src/api => crates/codex-api/src}/routes/v1/dto/series_export.rs (100%) rename {src/api => crates/codex-api/src}/routes/v1/dto/settings.rs (100%) rename {src/api => crates/codex-api/src}/routes/v1/dto/setup.rs (100%) rename {src/api => crates/codex-api/src}/routes/v1/dto/sharing_tag.rs (100%) rename {src/api => crates/codex-api/src}/routes/v1/dto/task_metrics.rs (100%) rename {src/api => crates/codex-api/src}/routes/v1/dto/tracking.rs (100%) rename {src/api => crates/codex-api/src}/routes/v1/dto/user.rs (99%) rename {src/api => crates/codex-api/src}/routes/v1/dto/user_plugins.rs (100%) rename {src/api => crates/codex-api/src}/routes/v1/dto/user_preferences.rs (100%) rename {src/api => crates/codex-api/src}/routes/v1/handlers/api_keys.rs (99%) rename {src/api => crates/codex-api/src}/routes/v1/handlers/auth.rs (99%) rename {src/api => crates/codex-api/src}/routes/v1/handlers/books.rs (99%) rename {src/api => crates/codex-api/src}/routes/v1/handlers/bulk.rs (99%) rename {src/api => crates/codex-api/src}/routes/v1/handlers/bulk_metadata.rs (99%) rename {src/api => crates/codex-api/src}/routes/v1/handlers/cleanup.rs (99%) rename {src/api => crates/codex-api/src}/routes/v1/handlers/duplicates.rs (99%) rename {src/api => crates/codex-api/src}/routes/v1/handlers/events.rs (99%) rename {src/api => crates/codex-api/src}/routes/v1/handlers/filesystem.rs (98%) rename {src/api => crates/codex-api/src}/routes/v1/handlers/filter_presets.rs (98%) rename {src/api => crates/codex-api/src}/routes/v1/handlers/health.rs (100%) rename {src/api => crates/codex-api/src}/routes/v1/handlers/info.rs (60%) rename {src/api => crates/codex-api/src}/routes/v1/handlers/libraries.rs (99%) rename {src/api => crates/codex-api/src}/routes/v1/handlers/library_jobs.rs (99%) rename {src/api => crates/codex-api/src}/routes/v1/handlers/metrics.rs (98%) rename {src/api => crates/codex-api/src}/routes/v1/handlers/mod.rs (100%) rename {src/api => crates/codex-api/src}/routes/v1/handlers/observability.rs (99%) rename {src/api => crates/codex-api/src}/routes/v1/handlers/oidc.rs (99%) rename {src/api => crates/codex-api/src}/routes/v1/handlers/pages.rs (99%) rename {src/api => crates/codex-api/src}/routes/v1/handlers/pdf_cache.rs (99%) rename {src/api => crates/codex-api/src}/routes/v1/handlers/plugin_actions.rs (99%) rename {src/api => crates/codex-api/src}/routes/v1/handlers/plugin_storage.rs (99%) rename {src/api => crates/codex-api/src}/routes/v1/handlers/plugins.rs (99%) rename {src/api => crates/codex-api/src}/routes/v1/handlers/read_progress.rs (99%) rename {src/api => crates/codex-api/src}/routes/v1/handlers/recommendations.rs (99%) rename {src/api => crates/codex-api/src}/routes/v1/handlers/releases.rs (99%) rename {src/api => crates/codex-api/src}/routes/v1/handlers/scan.rs (99%) rename {src/api => crates/codex-api/src}/routes/v1/handlers/series.rs (99%) rename {src/api => crates/codex-api/src}/routes/v1/handlers/series_exports.rs (99%) rename {src/api => crates/codex-api/src}/routes/v1/handlers/settings.rs (98%) rename {src/api => crates/codex-api/src}/routes/v1/handlers/setup.rs (99%) rename {src/api => crates/codex-api/src}/routes/v1/handlers/sharing_tags.rs (99%) rename {src/api => crates/codex-api/src}/routes/v1/handlers/task_metrics.rs (98%) rename {src/api => crates/codex-api/src}/routes/v1/handlers/task_queue.rs (99%) rename {src/api => crates/codex-api/src}/routes/v1/handlers/tracking.rs (99%) rename {src/api => crates/codex-api/src}/routes/v1/handlers/user_plugins.rs (99%) rename {src/api => crates/codex-api/src}/routes/v1/handlers/user_preferences.rs (99%) rename {src/api => crates/codex-api/src}/routes/v1/handlers/users.rs (99%) rename {src/api => crates/codex-api/src}/routes/v1/mod.rs (97%) rename {src/api => crates/codex-api/src}/routes/v1/routes/admin.rs (99%) rename {src/api => crates/codex-api/src}/routes/v1/routes/auth.rs (96%) rename {src/api => crates/codex-api/src}/routes/v1/routes/books.rs (99%) rename {src/api => crates/codex-api/src}/routes/v1/routes/libraries.rs (99%) rename {src/api => crates/codex-api/src}/routes/v1/routes/misc.rs (99%) rename {src/api => crates/codex-api/src}/routes/v1/routes/mod.rs (97%) rename {src/api => crates/codex-api/src}/routes/v1/routes/observability.rs (97%) rename {src/api => crates/codex-api/src}/routes/v1/routes/oidc.rs (95%) rename {src/api => crates/codex-api/src}/routes/v1/routes/plugins.rs (95%) rename {src/api => crates/codex-api/src}/routes/v1/routes/recommendations.rs (96%) rename {src/api => crates/codex-api/src}/routes/v1/routes/releases.rs (98%) rename {src/api => crates/codex-api/src}/routes/v1/routes/series.rs (99%) rename {src/api => crates/codex-api/src}/routes/v1/routes/setup.rs (95%) rename {src/api => crates/codex-api/src}/routes/v1/routes/tasks.rs (98%) rename {src/api => crates/codex-api/src}/routes/v1/routes/user.rs (98%) rename {src/api => crates/codex-api/src}/routes/v1/routes/user_plugins.rs (98%) rename {src/api => crates/codex-api/src}/routes/v1/routes/users.rs (97%) rename {src => crates/codex-api/src}/web.rs (100%) diff --git a/Cargo.lock b/Cargo.lock index 39108f2b..6c18b034 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -811,16 +811,62 @@ dependencies = [ name = "codex" version = "1.29.0" dependencies = [ - "aes-gcm", "anyhow", - "argon2", + "axum", + "base64 0.22.1", + "chrono", + "clap", + "codex-api", + "codex-config", + "codex-db", + "codex-events", + "codex-models", + "codex-parsers", + "codex-scanner", + "codex-scheduler", + "codex-search", + "codex-services", + "codex-tasks", + "codex-utils", + "http-body-util", + "hyper", + "image", + "lopdf", + "migration", + "opentelemetry 0.32.0", + "opentelemetry_sdk", + "rand 0.10.0", + "sea-orm", + "serde", + "serde_json", + "serde_yaml", + "serial_test", + "tabled", + "tempfile", + "tokio", + "tokio-util", + "tower", + "tracing", + "tracing-appender", + "tracing-opentelemetry 0.33.0", + "tracing-subscriber", + "tracing-test", + "utoipa", + "uuid", + "walkdir", + "zip", +] + +[[package]] +name = "codex-api" +version = "0.0.0" +dependencies = [ + "anyhow", "async-stream", "axum", "axum-tracing-opentelemetry", "base64 0.22.1", "chrono", - "chrono-tz", - "clap", "codex-config", "codex-db", "codex-events", @@ -832,69 +878,42 @@ dependencies = [ "codex-services", "codex-tasks", "codex-utils", - "cron", - "csv", "dashmap", "dirs", "futures", "globset", - "handlebars", "http-body-util", "httpdate", "hyper", "image", - "jsonwebtoken", - "jxl-oxide", - "lazy_static", - "lettre", "log", - "lopdf", - "lru", - "md-5", "migration", "mime_guess", - "nucleo-matcher", - "openidconnect", "opentelemetry 0.32.0", "opentelemetry-otlp", "opentelemetry-semantic-conventions 0.32.0", "opentelemetry_sdk", - "parking_lot", - "pdfium-render", "quick-xml", "rand 0.10.0", - "regex", "reqwest 0.13.2", - "resvg", "rust-embed", "sea-orm", "serde", "serde_json", - "serde_yaml", "serial_test", - "sha2", - "sysinfo", - "tabled", "tempfile", - "thiserror 2.0.18", "tokio", - "tokio-cron-scheduler", - "tokio-stream", "tokio-util", "tonic", "tower", "tower-http", "tracing", - "tracing-appender", "tracing-opentelemetry 0.33.0", "tracing-subscriber", - "tracing-test", - "unicode-normalization", "urlencoding", "utoipa", "utoipa-scalar", "uuid", - "walkdir", "zip", ] diff --git a/Cargo.toml b/Cargo.toml index 8bf6cae9..7ecf0e13 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,19 +14,22 @@ path = "src/main.rs" [features] default = ["rar", "observability"] -rar = ["codex-parsers/rar", "codex-scanner/rar", "codex-services/rar", "codex-tasks/rar"] -embed-frontend = [] -observability = [ - "dep:opentelemetry", - "dep:opentelemetry_sdk", - "dep:opentelemetry-otlp", - "dep:opentelemetry-semantic-conventions", - "dep:tracing-opentelemetry", - "dep:axum-tracing-opentelemetry", - "dep:tonic", - "dep:sysinfo", - "codex-services/observability", +# Forwards CBR support down through the dependency stack. Every workspace +# member that touches archive parsing owns its own `rar` feature; this is the +# top-level switch. +rar = [ + "codex-api/rar", + "codex-parsers/rar", + "codex-scanner/rar", + "codex-services/rar", + "codex-tasks/rar", ] +# Embeds the React frontend assets into the binary at build time. +embed-frontend = ["codex-api/embed-frontend"] +# Enables the OpenTelemetry HTTP/runtime instrumentation in codex-api. +# Root composes the OTel `tracing-opentelemetry` bridge layer in init_tracing, +# so the dep is enabled here too. +observability = ["codex-api/observability", "dep:tracing-opentelemetry"] [workspace] members = [ @@ -43,6 +46,7 @@ members = [ "crates/codex-scanner", "crates/codex-tasks", "crates/codex-scheduler", + "crates/codex-api", ] # Shared dependencies inherited by workspace members. Only deps that are @@ -65,6 +69,7 @@ uuid = { version = "1.0", features = ["v4", "serde"] } # Workspace-internal crates. Declaring them here keeps cross-crate path edges # in one place so members reference each other via `{ workspace = true }`. +codex-api = { path = "crates/codex-api", default-features = false } codex-config = { path = "crates/codex-config" } codex-db = { path = "crates/codex-db" } codex-events = { path = "crates/codex-events" } @@ -85,73 +90,18 @@ serial_test = "3.2" # CLI clap = { version = "4", features = ["derive"] } -# Serialization -serde = { workspace = true } -serde_yaml = { workspace = true } -serde_json = "1.0" -csv = "1.3" - -# Configuration (config crate removed - not used, config loaded via serde_yaml) - -# Archive formats -zip = "8.1" - -# PDF runtime handle cache (pdfium_render types are re-exposed by services) -pdfium-render = { version = "0.8", features = ["sync"] } - -# Image processing -image = { version = "0.25", features = ["avif"] } -resvg = "0.47" -jxl-oxide = "0.12" - -# Hashing -sha2 = "0.10" -md-5 = "0.10" - -# Error handling +# Workspace-inherited anyhow = { workspace = true } -thiserror = "2.0" - -# XML parsing (for ComicInfo.xml) -quick-xml = { version = "0.39", features = ["serialize"] } - -# Unicode normalization (for accent-insensitive search) -unicode-normalization = "0.1" - -# Regular expressions (for ISBN extraction) -regex = "1.10" - -# Templating (for plugin search query templates) -handlebars = "6" - -# URL encoding -urlencoding = "2.1" - -# File system utilities -walkdir = "2.5" -dirs = "6.0" -globset = "0.4" - -# Date/time chrono = { workspace = true } -chrono-tz = "0.10" -httpdate = "1.0" - -# Table formatting -tabled = "0.20" +serde = { workspace = true } +serde_yaml = { workspace = true } +tokio = { workspace = true } +tracing = { workspace = true } +utoipa = { workspace = true } +uuid = { workspace = true } -# Database -sea-orm = { version = "1.1", features = [ - "sqlx-postgres", - "sqlx-sqlite", - "runtime-tokio-rustls", - "macros", - "with-chrono", - "with-uuid", -] } -# `sea-orm-migration` lives in codex-db; root reaches the `migration` crate -# directly for the oidc handler's one-off Migrator::up call. -migration = { path = "migration" } +# Workspace-internal +codex-api = { workspace = true } codex-config = { workspace = true } codex-db = { workspace = true } codex-events = { workspace = true } @@ -163,113 +113,78 @@ codex-scheduler = { workspace = true } codex-services = { workspace = true } codex-tasks = { workspace = true } codex-utils = { workspace = true } -tokio = { workspace = true } -uuid = { workspace = true } -# Web framework -axum = { version = "0.8", features = ["multipart"] } -tower = "0.5" -tower-http = { version = "0.6", features = ["trace", "cors", "catch-panic"] } -tracing = { workspace = true } -tracing-subscriber = { version = "0.3", features = ["env-filter"] } -tracing-appender = "0.2" -log = "0.4" # For sqlx logging level configuration +# Serialization (commands use json output formats) +serde_json = "1.0" -# OpenTelemetry (optional, gated by `observability` feature) -opentelemetry = { version = "0.32", optional = true } -opentelemetry_sdk = { version = "0.32", features = ["rt-tokio", "trace", "metrics"], optional = true } -opentelemetry-otlp = { version = "0.32", default-features = false, features = [ - "grpc-tonic", - "http-proto", - "http-json", - # Blocking HTTP client is intentional: the OTel SDK 0.32 batch processor - # runs export on a dedicated std::thread that has no async runtime - # attached. An async reqwest client would panic on first export. The - # blocking client only blocks the batch thread, not the server runtime. - "reqwest-blocking-client", - "trace", - "metrics", -], optional = true } -opentelemetry-semantic-conventions = { version = "0.32", optional = true } -tracing-opentelemetry = { version = "0.33", optional = true } -axum-tracing-opentelemetry = { version = "0.33", optional = true } -# Re-used via opentelemetry-otlp's grpc-tonic feature; declared here so -# metadata helpers can use MetadataKey/MetadataValue types directly. -tonic = { version = "0.14", default-features = false, optional = true } -# Process-level metrics (CPU, memory). `opentelemetry-system-metrics` would -# do this for us but is pinned to opentelemetry 0.31, one minor behind our -# 0.32. Rolling the few callbacks we need against sysinfo directly is ~30 lines -# and keeps the toolchain consistent. -sysinfo = { version = "0.39", default-features = false, features = ["system"], optional = true } -async-stream = "0.3" -futures = "0.3" -tokio-stream = "0.1" +# Database (commands inspect DatabaseBackend at runtime to log the active +# driver in init_database). +sea-orm = { version = "1.1", features = [ + "sqlx-postgres", + "sqlx-sqlite", + "runtime-tokio-rustls", + "macros", + "with-chrono", + "with-uuid", +] } -# Authentication & Security -jsonwebtoken = { version = "10", features = ["aws_lc_rs"] } -argon2 = "0.5" +# Random for seed defaults rand = "0.10" -lazy_static = "1.4" -base64 = "0.22" -openidconnect = "4" - -# Encryption -aes-gcm = "0.10" -# Email -lettre = { version = "0.11", default-features = false, features = [ - "tokio1", - "tokio1-rustls-tls", - "smtp-transport", - "builder", -] } - -# HTTP Client (for plugin cover downloads and OAuth token exchange) -reqwest = { version = "0.13", default-features = false, features = [ - "rustls", - "json", - "form", -] } +# Async helpers (commands/serve.rs uses tokio_util::sync::CancellationToken) +tokio-util = { version = "0.7", features = ["io"] } -# API Documentation -utoipa = { workspace = true } -utoipa-scalar = { version = "0.3", features = ["axum"] } +# Tracing subscribers / appenders (commands/common.rs init_tracing). +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +tracing-appender = "0.2" -# Job Scheduling -cron = "0.13" -tokio-cron-scheduler = "0.15" -tokio-util = { version = "0.7", features = ["io"] } +# Tabular output (commands/tasks.rs admin views) +tabled = "0.20" -# Concurrent data structures -dashmap = "6.1" -lru = "0.18" -parking_lot = "0.12" +# Axum (`axum::serve` in commands/serve.rs to bind the router on the listener). +axum = { version = "0.8", features = ["multipart"] } -# Fuzzy matching (in-memory search index) -nucleo-matcher = "0.3" +# Recursive file walking (commands/scan.rs) +walkdir = "2.5" -# Static file embedding for frontend -rust-embed = "8.5" -mime_guess = "2.0" +# OpenTelemetry bridge layer composition in init_tracing. Optional so a +# `--no-default-features` build drops the dep entirely; enabled together with +# the root-level `observability` feature. +tracing-opentelemetry = { version = "0.33", optional = true } [dev-dependencies] tempfile = { workspace = true } tower = { version = "0.5", features = ["util"] } http-body-util = "0.1" hyper = { version = "1.0", features = ["full"] } +axum = { version = "0.8", features = ["multipart"] } +image = { version = "0.25", features = ["avif"] } +zip = "8.1" +migration = { path = "migration" } serial_test = { workspace = true } tracing-test = "0.2" # Enables codex_db::test_helpers (gated behind the `test-utils` feature) so -# the root crate's `#[cfg(test)]` blocks and the `tests/` integration suite -# can mint SQLite test databases. +# the root crate's integration suite under `tests/` can mint SQLite test +# databases. codex-db = { workspace = true, features = ["test-utils"] } +# Tests assert against the api crate's behaviour via `codex::api::*`. Pull +# codex-api in directly so `[features]` propagation works for tests that need +# the `observability` feature surface (see tests/api/observability.rs). +codex-api = { workspace = true, features = ["observability"] } # Used by tests/common/files.rs to mint PDF fixtures. The runtime PDF # rendering path lives in codex-parsers; tests reach for lopdf directly to # craft byte-level inputs. lopdf = "0.39" # Enable the SDK's `testing` feature for the in-memory metric exporter used -# in observability::metrics tests. Dev-only; no production impact. +# in observability tests reachable via `codex::observability`. opentelemetry_sdk = { version = "0.32", features = ["rt-tokio", "trace", "metrics", "testing"] } +# Used directly by tests/api/auth.rs (Basic-auth header construction) and a +# couple of OPDS tests. +base64 = "0.22" +# Used by tests/api/observability.rs to assert that the registered OTel trace +# context flows into request handlers. +opentelemetry = "0.32" +tracing-opentelemetry = "0.33" # ============================================================================= # Development Profile - Optimized for fast incremental builds diff --git a/crates/codex-api/Cargo.toml b/crates/codex-api/Cargo.toml new file mode 100644 index 00000000..49ce935d --- /dev/null +++ b/crates/codex-api/Cargo.toml @@ -0,0 +1,157 @@ +[package] +name = "codex-api" +version = "0.0.0" +edition = "2024" +publish = false + +[lib] +name = "codex_api" +path = "src/lib.rs" + +[features] +default = [] +# Forwards to codex-parsers/rar so CBR-aware handlers (pages, thumbnails, +# komga manifest) compile with UnRAR support. +rar = ["codex-parsers/rar", "codex-scanner/rar", "codex-services/rar", "codex-tasks/rar"] +# Embeds the React frontend assets into the binary (see src/web.rs). +embed-frontend = [] +# Enables OTel HTTP tracing + meter providers. When off, observability is a +# set of no-op stubs and the OTel deps drop out of the build entirely. +observability = [ + "dep:opentelemetry", + "dep:opentelemetry_sdk", + "dep:opentelemetry-otlp", + "dep:opentelemetry-semantic-conventions", + "dep:tracing-opentelemetry", + "dep:axum-tracing-opentelemetry", + "dep:tonic", + "codex-services/observability", +] + +[dependencies] +# Workspace-inherited +anyhow = { workspace = true } +chrono = { workspace = true } +serde = { workspace = true } +tokio = { workspace = true } +tracing = { workspace = true } +utoipa = { workspace = true } +uuid = { workspace = true } + +# Workspace-internal +codex-config = { workspace = true } +codex-db = { workspace = true } +# The OIDC bootstrap handler runs `Migrator::up` after re-opening the +# connection with new credentials, so api needs the migration crate directly. +migration = { path = "../../migration" } +codex-events = { workspace = true } +codex-models = { workspace = true } +codex-parsers = { workspace = true } +codex-scanner = { workspace = true } +codex-scheduler = { workspace = true } +codex-search = { workspace = true } +codex-services = { workspace = true } +codex-tasks = { workspace = true } +codex-utils = { workspace = true } + +# Web framework +axum = { version = "0.8", features = ["multipart"] } +tower = "0.5" +tower-http = { version = "0.6", features = ["trace", "cors", "catch-panic"] } + +# Database (direct queries in some handlers) +sea-orm = { version = "1.1", features = [ + "sqlx-postgres", + "sqlx-sqlite", + "runtime-tokio-rustls", + "macros", + "with-chrono", + "with-uuid", +] } + +# Serialization +serde_json = "1.0" + +# Async helpers +async-stream = "0.3" +futures = "0.3" +tokio-util = { version = "0.7", features = ["io"] } + +# OpenAPI / Scalar +utoipa-scalar = { version = "0.3", features = ["axum"] } + +# Rate-limit / OAuth state caches +dashmap = "6.1" + +# Glob matching for rate-limit allow/deny lists +globset = "0.4" + +# HTTP date parsing for If-Modified-Since handling +httpdate = "1.0" + +# Image processing for cover/thumbnail handlers +image = { version = "0.25", features = ["avif"] } + +# Encoding helpers for auth handlers + CSRF state +base64 = "0.22" +rand = "0.10" + +# Archive utilities for export handlers +zip = "8.1" + +# Static file embedding for frontend (gated by `embed-frontend`) +rust-embed = "8.5" +mime_guess = "2.0" + +# Logging (for sqlx log filter adjustments inside init paths) +log = "0.4" + +# OPDS Atom XML serialization +quick-xml = { version = "0.39", features = ["serialize"] } + +# HTTP client (observability proxy) +reqwest = { version = "0.13", default-features = false, features = [ + "rustls", + "json", + "form", +] } + +# URL encoding (OPDS search, OIDC error redirects, koreader sync) +urlencoding = "2.1" + +# Filesystem helpers (filesystem-picker handler resolves $HOME defaults) +dirs = "6.0" + +# OpenTelemetry (optional, gated by `observability` feature) +opentelemetry = { version = "0.32", optional = true } +opentelemetry_sdk = { version = "0.32", features = ["rt-tokio", "trace", "metrics"], optional = true } +opentelemetry-otlp = { version = "0.32", default-features = false, features = [ + "grpc-tonic", + "http-proto", + "http-json", + # Blocking HTTP client is intentional: the OTel SDK 0.32 batch processor + # runs export on a dedicated std::thread that has no async runtime + # attached. An async reqwest client would panic on first export. The + # blocking client only blocks the batch thread, not the server runtime. + "reqwest-blocking-client", + "trace", + "metrics", +], optional = true } +opentelemetry-semantic-conventions = { version = "0.32", optional = true } +tracing-opentelemetry = { version = "0.33", optional = true } +axum-tracing-opentelemetry = { version = "0.33", optional = true } +# Used via opentelemetry-otlp's grpc-tonic feature; declared here so metadata +# helpers can use MetadataKey/MetadataValue types directly. +tonic = { version = "0.14", default-features = false, optional = true } +# Tracing subscriber types referenced by the trace-context formatter. +tracing-subscriber = { version = "0.3", features = ["env-filter"] } + +[dev-dependencies] +tempfile = { workspace = true } +serial_test = { workspace = true } +codex-db = { workspace = true, features = ["test-utils"] } +tower = { version = "0.5", features = ["util"] } +http-body-util = "0.1" +hyper = { version = "1.0", features = ["full"] } +# In-memory metric exporter for observability tests inside this crate. +opentelemetry_sdk = { version = "0.32", features = ["rt-tokio", "trace", "metrics", "testing"] } diff --git a/crates/codex-api/build.rs b/crates/codex-api/build.rs new file mode 100644 index 00000000..fa14f2de --- /dev/null +++ b/crates/codex-api/build.rs @@ -0,0 +1,34 @@ +//! Surface the codex binary's version to the API documentation generator. +//! +//! The OpenAPI spec embeds a `version` string at compile time via the +//! `utoipa::OpenApi` derive. Inside the `codex-api` crate, `env!("CARGO_PKG_VERSION")` +//! resolves to this crate's own `0.0.0` workspace-internal placeholder, which +//! is not the user-visible version. Read the root `Cargo.toml` once here and +//! re-emit it as a build-time env var the derive can pick up. + +use std::path::PathBuf; + +fn main() { + // Root manifest is two levels up from this crate's manifest dir. + let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let root_manifest = manifest_dir + .parent() + .and_then(|p| p.parent()) + .map(|p| p.join("Cargo.toml")) + .expect("codex-api should live under <workspace>/crates/codex-api"); + + println!("cargo:rerun-if-changed={}", root_manifest.display()); + + let contents = std::fs::read_to_string(&root_manifest) + .unwrap_or_else(|e| panic!("read {}: {e}", root_manifest.display())); + + let version = contents + .lines() + .skip_while(|l| l.trim() != "[package]") + .find_map(|l| l.trim().strip_prefix("version = ")) + .and_then(|v| v.trim().strip_prefix('"')) + .and_then(|v| v.strip_suffix('"')) + .expect("root Cargo.toml must have a `version = \"...\"` line in [package]"); + + println!("cargo:rustc-env=CODEX_BIN_VERSION={version}"); +} diff --git a/src/api/docs.rs b/crates/codex-api/src/docs.rs similarity index 99% rename from src/api/docs.rs rename to crates/codex-api/src/docs.rs index 3c76f5c1..e7409864 100644 --- a/src/api/docs.rs +++ b/crates/codex-api/src/docs.rs @@ -1,4 +1,4 @@ -use crate::api::{ +use crate::{ error::ErrorResponse, routes::{komga, opds, opds2, v1}, }; @@ -12,7 +12,10 @@ use utoipa::OpenApi; #[openapi( info( title = "Codex API", - version = env!("CARGO_PKG_VERSION"), + // `CODEX_BIN_VERSION` is wired up in this crate's build.rs by reading + // the workspace-root Cargo.toml; otherwise this would resolve to the + // crate's internal `0.0.0` placeholder. + version = env!("CODEX_BIN_VERSION"), description = r#"REST API for Codex, a digital library server for comics, manga, and ebooks. ## Interactive API Documentation diff --git a/src/api/error.rs b/crates/codex-api/src/error.rs similarity index 100% rename from src/api/error.rs rename to crates/codex-api/src/error.rs diff --git a/src/api/extractors/auth.rs b/crates/codex-api/src/extractors/auth.rs similarity index 98% rename from src/api/extractors/auth.rs rename to crates/codex-api/src/extractors/auth.rs index 179e4c26..fdfb239c 100644 --- a/src/api/extractors/auth.rs +++ b/crates/codex-api/src/extractors/auth.rs @@ -1,7 +1,7 @@ use tracing::debug; -use crate::api::error::ApiError; -use crate::api::permissions::{Permission, UserRole}; +use crate::error::ApiError; +use crate::permissions::{Permission, UserRole}; use axum::http::header::COOKIE; use axum::{extract::FromRequestParts, http::request::Parts}; use chrono::{DateTime, Utc}; @@ -246,6 +246,13 @@ pub struct AppState { /// Phase 3 — the handler falls back to the existing LIKE search when off. #[allow(dead_code)] pub fuzzy_index: Arc<codex_search::FuzzyIndex>, + /// Application name surfaced by `GET /api/v1/info`. + /// Read from `env!("CARGO_PKG_NAME")` by the binary at startup so the + /// reported value tracks the binary crate, not this library crate. + pub app_name: &'static str, + /// Application version surfaced by `GET /api/v1/info`. Same plumbing as + /// [`AppState::app_name`]. + pub app_version: &'static str, } // Legacy alias for backwards compatibility during transition diff --git a/src/api/extractors/client_info.rs b/crates/codex-api/src/extractors/client_info.rs similarity index 100% rename from src/api/extractors/client_info.rs rename to crates/codex-api/src/extractors/client_info.rs diff --git a/src/api/extractors/mod.rs b/crates/codex-api/src/extractors/mod.rs similarity index 100% rename from src/api/extractors/mod.rs rename to crates/codex-api/src/extractors/mod.rs diff --git a/src/api/mod.rs b/crates/codex-api/src/lib.rs similarity index 85% rename from src/api/mod.rs rename to crates/codex-api/src/lib.rs index 9959121a..cde6c22e 100644 --- a/src/api/mod.rs +++ b/crates/codex-api/src/lib.rs @@ -2,8 +2,10 @@ pub mod docs; pub mod error; pub mod extractors; pub mod middleware; +pub mod observability; pub mod permissions; pub mod routes; +pub mod web; #[allow(unused_imports)] pub use docs::ApiDoc; diff --git a/src/api/middleware/auth.rs b/crates/codex-api/src/middleware/auth.rs similarity index 100% rename from src/api/middleware/auth.rs rename to crates/codex-api/src/middleware/auth.rs diff --git a/src/api/middleware/http_metrics.rs b/crates/codex-api/src/middleware/http_metrics.rs similarity index 100% rename from src/api/middleware/http_metrics.rs rename to crates/codex-api/src/middleware/http_metrics.rs diff --git a/src/api/middleware/mod.rs b/crates/codex-api/src/middleware/mod.rs similarity index 100% rename from src/api/middleware/mod.rs rename to crates/codex-api/src/middleware/mod.rs diff --git a/src/api/middleware/permissions.rs b/crates/codex-api/src/middleware/permissions.rs similarity index 100% rename from src/api/middleware/permissions.rs rename to crates/codex-api/src/middleware/permissions.rs diff --git a/src/api/middleware/rate_limit.rs b/crates/codex-api/src/middleware/rate_limit.rs similarity index 100% rename from src/api/middleware/rate_limit.rs rename to crates/codex-api/src/middleware/rate_limit.rs diff --git a/src/api/middleware/tracing.rs b/crates/codex-api/src/middleware/tracing.rs similarity index 100% rename from src/api/middleware/tracing.rs rename to crates/codex-api/src/middleware/tracing.rs diff --git a/src/observability/http.rs b/crates/codex-api/src/observability/http.rs similarity index 100% rename from src/observability/http.rs rename to crates/codex-api/src/observability/http.rs diff --git a/src/observability/inventory.rs b/crates/codex-api/src/observability/inventory.rs similarity index 100% rename from src/observability/inventory.rs rename to crates/codex-api/src/observability/inventory.rs diff --git a/src/observability/mod.rs b/crates/codex-api/src/observability/mod.rs similarity index 100% rename from src/observability/mod.rs rename to crates/codex-api/src/observability/mod.rs diff --git a/src/observability/providers.rs b/crates/codex-api/src/observability/providers.rs similarity index 100% rename from src/observability/providers.rs rename to crates/codex-api/src/observability/providers.rs diff --git a/src/observability/stub.rs b/crates/codex-api/src/observability/stub.rs similarity index 100% rename from src/observability/stub.rs rename to crates/codex-api/src/observability/stub.rs diff --git a/src/observability/trace_fmt.rs b/crates/codex-api/src/observability/trace_fmt.rs similarity index 100% rename from src/observability/trace_fmt.rs rename to crates/codex-api/src/observability/trace_fmt.rs diff --git a/src/api/permissions.rs b/crates/codex-api/src/permissions.rs similarity index 100% rename from src/api/permissions.rs rename to crates/codex-api/src/permissions.rs diff --git a/src/api/routes/komga/dto/book.rs b/crates/codex-api/src/routes/komga/dto/book.rs similarity index 100% rename from src/api/routes/komga/dto/book.rs rename to crates/codex-api/src/routes/komga/dto/book.rs diff --git a/src/api/routes/komga/dto/library.rs b/crates/codex-api/src/routes/komga/dto/library.rs similarity index 100% rename from src/api/routes/komga/dto/library.rs rename to crates/codex-api/src/routes/komga/dto/library.rs diff --git a/src/api/routes/komga/dto/manifest.rs b/crates/codex-api/src/routes/komga/dto/manifest.rs similarity index 100% rename from src/api/routes/komga/dto/manifest.rs rename to crates/codex-api/src/routes/komga/dto/manifest.rs diff --git a/src/api/routes/komga/dto/mod.rs b/crates/codex-api/src/routes/komga/dto/mod.rs similarity index 100% rename from src/api/routes/komga/dto/mod.rs rename to crates/codex-api/src/routes/komga/dto/mod.rs diff --git a/src/api/routes/komga/dto/page.rs b/crates/codex-api/src/routes/komga/dto/page.rs similarity index 100% rename from src/api/routes/komga/dto/page.rs rename to crates/codex-api/src/routes/komga/dto/page.rs diff --git a/src/api/routes/komga/dto/pagination.rs b/crates/codex-api/src/routes/komga/dto/pagination.rs similarity index 100% rename from src/api/routes/komga/dto/pagination.rs rename to crates/codex-api/src/routes/komga/dto/pagination.rs diff --git a/src/api/routes/komga/dto/series.rs b/crates/codex-api/src/routes/komga/dto/series.rs similarity index 100% rename from src/api/routes/komga/dto/series.rs rename to crates/codex-api/src/routes/komga/dto/series.rs diff --git a/src/api/routes/komga/dto/stubs.rs b/crates/codex-api/src/routes/komga/dto/stubs.rs similarity index 100% rename from src/api/routes/komga/dto/stubs.rs rename to crates/codex-api/src/routes/komga/dto/stubs.rs diff --git a/src/api/routes/komga/dto/user.rs b/crates/codex-api/src/routes/komga/dto/user.rs similarity index 100% rename from src/api/routes/komga/dto/user.rs rename to crates/codex-api/src/routes/komga/dto/user.rs diff --git a/src/api/routes/komga/handlers/books.rs b/crates/codex-api/src/routes/komga/handlers/books.rs similarity index 99% rename from src/api/routes/komga/handlers/books.rs rename to crates/codex-api/src/routes/komga/handlers/books.rs index c210f5a0..b4c82a07 100644 --- a/src/api/routes/komga/handlers/books.rs +++ b/crates/codex-api/src/routes/komga/handlers/books.rs @@ -9,12 +9,12 @@ use super::super::dto::book::{ }; use super::super::dto::pagination::KomgaPage; use super::libraries::{extract_page_image, generate_thumbnail}; -use crate::api::{ +use crate::require_permission; +use crate::{ error::ApiError, extractors::{AuthState, FlexibleAuthContext}, permissions::Permission, }; -use crate::require_permission; use axum::{ Json, body::Body, diff --git a/src/api/routes/komga/handlers/libraries.rs b/crates/codex-api/src/routes/komga/handlers/libraries.rs similarity index 99% rename from src/api/routes/komga/handlers/libraries.rs rename to crates/codex-api/src/routes/komga/handlers/libraries.rs index f8b191b4..d8bbdf2d 100644 --- a/src/api/routes/komga/handlers/libraries.rs +++ b/crates/codex-api/src/routes/komga/handlers/libraries.rs @@ -3,12 +3,12 @@ //! Handlers for library-related endpoints in the Komga-compatible API. use super::super::dto::library::KomgaLibraryDto; -use crate::api::{ +use crate::require_permission; +use crate::{ error::ApiError, extractors::{AuthState, FlexibleAuthContext}, permissions::Permission, }; -use crate::require_permission; use axum::{ Json, body::Body, diff --git a/src/api/routes/komga/handlers/manifest.rs b/crates/codex-api/src/routes/komga/handlers/manifest.rs similarity index 99% rename from src/api/routes/komga/handlers/manifest.rs rename to crates/codex-api/src/routes/komga/handlers/manifest.rs index ece0ad5b..0ae96822 100644 --- a/src/api/routes/komga/handlers/manifest.rs +++ b/crates/codex-api/src/routes/komga/handlers/manifest.rs @@ -4,12 +4,12 @@ //! This enables apps like Komic to read EPUBs without downloading the entire file. use super::super::dto::manifest::{WebPubLink, WebPubManifest, WebPubTocEntry}; -use crate::api::{ +use crate::require_permission; +use crate::{ error::ApiError, extractors::{AuthState, FlexibleAuthContext}, permissions::Permission, }; -use crate::require_permission; use axum::{ body::Body, extract::{OriginalUri, Path, State}, diff --git a/src/api/routes/komga/handlers/mod.rs b/crates/codex-api/src/routes/komga/handlers/mod.rs similarity index 100% rename from src/api/routes/komga/handlers/mod.rs rename to crates/codex-api/src/routes/komga/handlers/mod.rs diff --git a/src/api/routes/komga/handlers/pages.rs b/crates/codex-api/src/routes/komga/handlers/pages.rs similarity index 99% rename from src/api/routes/komga/handlers/pages.rs rename to crates/codex-api/src/routes/komga/handlers/pages.rs index d412cd04..355130a3 100644 --- a/src/api/routes/komga/handlers/pages.rs +++ b/crates/codex-api/src/routes/komga/handlers/pages.rs @@ -5,12 +5,12 @@ use super::super::dto::page::KomgaPageDto; use super::libraries::{extract_page_image, generate_thumbnail}; -use crate::api::{ +use crate::require_permission; +use crate::{ error::ApiError, extractors::{AuthState, FlexibleAuthContext}, permissions::Permission, }; -use crate::require_permission; use axum::{ Json, body::Body, diff --git a/src/api/routes/komga/handlers/read_progress.rs b/crates/codex-api/src/routes/komga/handlers/read_progress.rs similarity index 99% rename from src/api/routes/komga/handlers/read_progress.rs rename to crates/codex-api/src/routes/komga/handlers/read_progress.rs index 779b4737..c8aa6b8e 100644 --- a/src/api/routes/komga/handlers/read_progress.rs +++ b/crates/codex-api/src/routes/komga/handlers/read_progress.rs @@ -5,12 +5,12 @@ //! and sync reading progress. use super::super::dto::book::KomgaReadProgressUpdateDto; -use crate::api::{ +use crate::require_permission; +use crate::{ error::ApiError, extractors::{AuthState, FlexibleAuthContext}, permissions::Permission, }; -use crate::require_permission; use axum::{ extract::{Path, State}, http::StatusCode, @@ -165,7 +165,7 @@ pub async fn delete_progress( #[cfg(test)] mod tests { - use crate::api::routes::komga::dto::book::KomgaReadProgressUpdateDto; + use crate::routes::komga::dto::book::KomgaReadProgressUpdateDto; #[test] fn test_update_dto_deserialization_komic_format() { diff --git a/src/api/routes/komga/handlers/series.rs b/crates/codex-api/src/routes/komga/handlers/series.rs similarity index 99% rename from src/api/routes/komga/handlers/series.rs rename to crates/codex-api/src/routes/komga/handlers/series.rs index b90209f8..5dfa2b81 100644 --- a/src/api/routes/komga/handlers/series.rs +++ b/crates/codex-api/src/routes/komga/handlers/series.rs @@ -10,12 +10,12 @@ use super::super::dto::series::{ codex_to_komga_reading_direction, codex_to_komga_status, extract_read_status_from_condition, }; use super::libraries::{extract_page_image, generate_thumbnail}; -use crate::api::{ +use crate::require_permission; +use crate::{ error::ApiError, extractors::{AuthState, FlexibleAuthContext}, permissions::Permission, }; -use crate::require_permission; use axum::{ Json, body::Body, diff --git a/src/api/routes/komga/handlers/stubs.rs b/crates/codex-api/src/routes/komga/handlers/stubs.rs similarity index 99% rename from src/api/routes/komga/handlers/stubs.rs rename to crates/codex-api/src/routes/komga/handlers/stubs.rs index 6c569728..72d925d2 100644 --- a/src/api/routes/komga/handlers/stubs.rs +++ b/crates/codex-api/src/routes/komga/handlers/stubs.rs @@ -6,12 +6,12 @@ use super::super::dto::pagination::KomgaPage; use super::super::dto::series::KomgaAuthorDto; use super::super::dto::stubs::{KomgaCollectionDto, KomgaReadListDto, StubPaginationQuery}; -use crate::api::{ +use crate::require_permission; +use crate::{ error::ApiError, extractors::{AuthState, FlexibleAuthContext}, permissions::Permission, }; -use crate::require_permission; use axum::{ Json, extract::{Query, State}, diff --git a/src/api/routes/komga/handlers/users.rs b/crates/codex-api/src/routes/komga/handlers/users.rs similarity index 97% rename from src/api/routes/komga/handlers/users.rs rename to crates/codex-api/src/routes/komga/handlers/users.rs index c64d9641..e3ecf629 100644 --- a/src/api/routes/komga/handlers/users.rs +++ b/crates/codex-api/src/routes/komga/handlers/users.rs @@ -5,12 +5,12 @@ //! information about the currently authenticated user. use super::super::dto::user::KomgaUserDto; -use crate::api::{ +use crate::require_permission; +use crate::{ error::ApiError, extractors::{AuthState, FlexibleAuthContext}, permissions::Permission, }; -use crate::require_permission; use axum::{Json, extract::State}; use std::sync::Arc; @@ -66,7 +66,7 @@ pub async fn get_current_user( #[cfg(test)] mod tests { - use crate::api::routes::komga::dto::user::KomgaUserDto; + use crate::routes::komga::dto::user::KomgaUserDto; #[test] fn test_user_dto_admin_mapping() { diff --git a/src/api/routes/komga/mod.rs b/crates/codex-api/src/routes/komga/mod.rs similarity index 98% rename from src/api/routes/komga/mod.rs rename to crates/codex-api/src/routes/komga/mod.rs index cf3d5248..f2be2676 100644 --- a/src/api/routes/komga/mod.rs +++ b/crates/codex-api/src/routes/komga/mod.rs @@ -51,7 +51,7 @@ pub mod dto; pub mod handlers; pub mod routes; -use crate::api::extractors::AppState; +use crate::extractors::AppState; use axum::Router; use std::sync::Arc; diff --git a/src/api/routes/komga/routes/books.rs b/crates/codex-api/src/routes/komga/routes/books.rs similarity index 97% rename from src/api/routes/komga/routes/books.rs rename to crates/codex-api/src/routes/komga/routes/books.rs index e47b3ea7..eae62bdc 100644 --- a/src/api/routes/komga/routes/books.rs +++ b/crates/codex-api/src/routes/komga/routes/books.rs @@ -3,7 +3,7 @@ //! Defines routes for book-related endpoints in the Komga-compatible API. use super::super::handlers; -use crate::api::extractors::AppState; +use crate::extractors::AppState; use axum::{ Router, routing::{get, post}, diff --git a/src/api/routes/komga/routes/libraries.rs b/crates/codex-api/src/routes/komga/routes/libraries.rs similarity index 95% rename from src/api/routes/komga/routes/libraries.rs rename to crates/codex-api/src/routes/komga/routes/libraries.rs index cd86f5f1..87d79ff3 100644 --- a/src/api/routes/komga/routes/libraries.rs +++ b/crates/codex-api/src/routes/komga/routes/libraries.rs @@ -3,7 +3,7 @@ //! Defines routes for library-related endpoints in the Komga-compatible API. use super::super::handlers; -use crate::api::extractors::AppState; +use crate::extractors::AppState; use axum::{Router, routing::get}; use std::sync::Arc; diff --git a/src/api/routes/komga/routes/mod.rs b/crates/codex-api/src/routes/komga/routes/mod.rs similarity index 98% rename from src/api/routes/komga/routes/mod.rs rename to crates/codex-api/src/routes/komga/routes/mod.rs index 503e63fd..fc9ffe91 100644 --- a/src/api/routes/komga/routes/mod.rs +++ b/crates/codex-api/src/routes/komga/routes/mod.rs @@ -11,7 +11,7 @@ mod series; mod stubs; mod users; -use crate::api::extractors::AppState; +use crate::extractors::AppState; use axum::Router; use std::sync::Arc; diff --git a/src/api/routes/komga/routes/pages.rs b/crates/codex-api/src/routes/komga/routes/pages.rs similarity index 96% rename from src/api/routes/komga/routes/pages.rs rename to crates/codex-api/src/routes/komga/routes/pages.rs index c755421b..78cfffbd 100644 --- a/src/api/routes/komga/routes/pages.rs +++ b/crates/codex-api/src/routes/komga/routes/pages.rs @@ -4,7 +4,7 @@ //! These routes handle page listing, streaming, and thumbnail generation. use super::super::handlers; -use crate::api::extractors::AppState; +use crate::extractors::AppState; use axum::{Router, routing::get}; use std::sync::Arc; diff --git a/src/api/routes/komga/routes/read_progress.rs b/crates/codex-api/src/routes/komga/routes/read_progress.rs similarity index 97% rename from src/api/routes/komga/routes/read_progress.rs rename to crates/codex-api/src/routes/komga/routes/read_progress.rs index 80213797..26a6255a 100644 --- a/src/api/routes/komga/routes/read_progress.rs +++ b/crates/codex-api/src/routes/komga/routes/read_progress.rs @@ -3,7 +3,7 @@ //! Defines routes for read progress endpoints in the Komga-compatible API. use super::super::handlers; -use crate::api::extractors::AppState; +use crate::extractors::AppState; use axum::{ Router, routing::{get, patch, post}, diff --git a/src/api/routes/komga/routes/series.rs b/crates/codex-api/src/routes/komga/routes/series.rs similarity index 97% rename from src/api/routes/komga/routes/series.rs rename to crates/codex-api/src/routes/komga/routes/series.rs index 84c7bf59..94007feb 100644 --- a/src/api/routes/komga/routes/series.rs +++ b/crates/codex-api/src/routes/komga/routes/series.rs @@ -3,7 +3,7 @@ //! Defines routes for series-related endpoints in the Komga-compatible API. use super::super::handlers; -use crate::api::extractors::AppState; +use crate::extractors::AppState; use axum::{ Router, routing::{get, post}, diff --git a/src/api/routes/komga/routes/stubs.rs b/crates/codex-api/src/routes/komga/routes/stubs.rs similarity index 97% rename from src/api/routes/komga/routes/stubs.rs rename to crates/codex-api/src/routes/komga/routes/stubs.rs index d8fa0c66..f0ff705b 100644 --- a/src/api/routes/komga/routes/stubs.rs +++ b/crates/codex-api/src/routes/komga/routes/stubs.rs @@ -4,7 +4,7 @@ //! but Codex doesn't fully support. This prevents 404 errors in the client. use super::super::handlers; -use crate::api::extractors::AppState; +use crate::extractors::AppState; use axum::{Router, routing::get}; use std::sync::Arc; diff --git a/src/api/routes/komga/routes/users.rs b/crates/codex-api/src/routes/komga/routes/users.rs similarity index 92% rename from src/api/routes/komga/routes/users.rs rename to crates/codex-api/src/routes/komga/routes/users.rs index 0ae00bf3..329f6055 100644 --- a/src/api/routes/komga/routes/users.rs +++ b/crates/codex-api/src/routes/komga/routes/users.rs @@ -3,7 +3,7 @@ //! Defines routes for user endpoints in the Komga-compatible API. use super::super::handlers; -use crate::api::extractors::AppState; +use crate::extractors::AppState; use axum::{Router, routing::get}; use std::sync::Arc; diff --git a/src/api/routes/koreader/dto/mod.rs b/crates/codex-api/src/routes/koreader/dto/mod.rs similarity index 100% rename from src/api/routes/koreader/dto/mod.rs rename to crates/codex-api/src/routes/koreader/dto/mod.rs diff --git a/src/api/routes/koreader/dto/progress.rs b/crates/codex-api/src/routes/koreader/dto/progress.rs similarity index 100% rename from src/api/routes/koreader/dto/progress.rs rename to crates/codex-api/src/routes/koreader/dto/progress.rs diff --git a/src/api/routes/koreader/handlers/auth.rs b/crates/codex-api/src/routes/koreader/handlers/auth.rs similarity index 82% rename from src/api/routes/koreader/handlers/auth.rs rename to crates/codex-api/src/routes/koreader/handlers/auth.rs index 9f5bec95..c53c2d4d 100644 --- a/src/api/routes/koreader/handlers/auth.rs +++ b/crates/codex-api/src/routes/koreader/handlers/auth.rs @@ -1,8 +1,8 @@ //! KOReader authentication handlers -use crate::api::error::ApiError; -use crate::api::extractors::AuthContext; -use crate::api::routes::koreader::dto::progress::AuthorizedDto; +use crate::error::ApiError; +use crate::extractors::AuthContext; +use crate::routes::koreader::dto::progress::AuthorizedDto; use axum::Json; use axum::http::StatusCode; diff --git a/src/api/routes/koreader/handlers/mod.rs b/crates/codex-api/src/routes/koreader/handlers/mod.rs similarity index 100% rename from src/api/routes/koreader/handlers/mod.rs rename to crates/codex-api/src/routes/koreader/handlers/mod.rs diff --git a/src/api/routes/koreader/handlers/sync.rs b/crates/codex-api/src/routes/koreader/handlers/sync.rs similarity index 98% rename from src/api/routes/koreader/handlers/sync.rs rename to crates/codex-api/src/routes/koreader/handlers/sync.rs index 8ce56e3d..905b6823 100644 --- a/src/api/routes/koreader/handlers/sync.rs +++ b/crates/codex-api/src/routes/koreader/handlers/sync.rs @@ -4,10 +4,10 @@ //! (Readium standard) so that progress is shared across all clients (web reader, //! KOReader, OPDS apps). -use crate::api::error::ApiError; -use crate::api::extractors::{AuthContext, AuthState}; -use crate::api::permissions::Permission; -use crate::api::routes::koreader::dto::progress::DocumentProgressDto; +use crate::error::ApiError; +use crate::extractors::{AuthContext, AuthState}; +use crate::permissions::Permission; +use crate::routes::koreader::dto::progress::DocumentProgressDto; use axum::Json; use axum::extract::{Path, State}; use codex_db::entities::books; diff --git a/src/api/routes/koreader/mod.rs b/crates/codex-api/src/routes/koreader/mod.rs similarity index 96% rename from src/api/routes/koreader/mod.rs rename to crates/codex-api/src/routes/koreader/mod.rs index 98d21fb1..83e59433 100644 --- a/src/api/routes/koreader/mod.rs +++ b/crates/codex-api/src/routes/koreader/mod.rs @@ -31,7 +31,7 @@ pub mod dto; pub mod handlers; pub mod routes; -use crate::api::extractors::AppState; +use crate::extractors::AppState; use axum::Router; use std::sync::Arc; diff --git a/src/api/routes/koreader/routes/mod.rs b/crates/codex-api/src/routes/koreader/routes/mod.rs similarity index 91% rename from src/api/routes/koreader/routes/mod.rs rename to crates/codex-api/src/routes/koreader/routes/mod.rs index b1b92d83..7555aeb1 100644 --- a/src/api/routes/koreader/routes/mod.rs +++ b/crates/codex-api/src/routes/koreader/routes/mod.rs @@ -1,7 +1,7 @@ //! KOReader sync API route definitions -use crate::api::extractors::AppState; -use crate::api::routes::koreader::handlers; +use crate::extractors::AppState; +use crate::routes::koreader::handlers; use axum::{ Router, routing::{get, post, put}, diff --git a/src/api/routes/mod.rs b/crates/codex-api/src/routes/mod.rs similarity index 97% rename from src/api/routes/mod.rs rename to crates/codex-api/src/routes/mod.rs index 22891f28..6dbaa143 100644 --- a/src/api/routes/mod.rs +++ b/crates/codex-api/src/routes/mod.rs @@ -4,9 +4,9 @@ pub mod opds; pub mod opds2; pub mod v1; -use crate::api::docs::ApiDoc; -use crate::api::extractors::AppState; -use crate::api::middleware::{RateLimitLayer, create_trace_layer}; +use crate::docs::ApiDoc; +use crate::extractors::AppState; +use crate::middleware::{RateLimitLayer, create_trace_layer}; use crate::web; use axum::{Router, routing::get}; use codex_config::Config; @@ -177,7 +177,7 @@ pub fn create_router(state: Arc<AppState>, config: &Config) -> Router { // is disabled). Layered after the trace layer so request timing here is // bounded by the same span the OTel server span covers. router = router.layer(axum::middleware::from_fn( - crate::api::middleware::http_metrics_middleware, + crate::middleware::http_metrics_middleware, )); // OpenTelemetry HTTP span / response context middleware (outermost layer). diff --git a/src/api/routes/opds/dto/entry.rs b/crates/codex-api/src/routes/opds/dto/entry.rs similarity index 100% rename from src/api/routes/opds/dto/entry.rs rename to crates/codex-api/src/routes/opds/dto/entry.rs diff --git a/src/api/routes/opds/dto/feed.rs b/crates/codex-api/src/routes/opds/dto/feed.rs similarity index 100% rename from src/api/routes/opds/dto/feed.rs rename to crates/codex-api/src/routes/opds/dto/feed.rs diff --git a/src/api/routes/opds/dto/link.rs b/crates/codex-api/src/routes/opds/dto/link.rs similarity index 100% rename from src/api/routes/opds/dto/link.rs rename to crates/codex-api/src/routes/opds/dto/link.rs diff --git a/src/api/routes/opds/dto/mod.rs b/crates/codex-api/src/routes/opds/dto/mod.rs similarity index 100% rename from src/api/routes/opds/dto/mod.rs rename to crates/codex-api/src/routes/opds/dto/mod.rs diff --git a/src/api/routes/opds/handlers/catalog.rs b/crates/codex-api/src/routes/opds/handlers/catalog.rs similarity index 99% rename from src/api/routes/opds/handlers/catalog.rs rename to crates/codex-api/src/routes/opds/handlers/catalog.rs index f16ed341..10e0071d 100644 --- a/src/api/routes/opds/handlers/catalog.rs +++ b/crates/codex-api/src/routes/opds/handlers/catalog.rs @@ -1,10 +1,10 @@ use super::super::dto::{OpdsEntry, OpdsFeed, OpdsLink}; -use crate::api::{ +use crate::require_permission; +use crate::{ error::ApiError, extractors::{AuthContext, AuthState}, permissions::Permission, }; -use crate::require_permission; use axum::{ extract::{Path, Query, State}, http::{StatusCode, header}, diff --git a/src/api/routes/opds/handlers/mod.rs b/crates/codex-api/src/routes/opds/handlers/mod.rs similarity index 100% rename from src/api/routes/opds/handlers/mod.rs rename to crates/codex-api/src/routes/opds/handlers/mod.rs diff --git a/src/api/routes/opds/handlers/pse.rs b/crates/codex-api/src/routes/opds/handlers/pse.rs similarity index 98% rename from src/api/routes/opds/handlers/pse.rs rename to crates/codex-api/src/routes/opds/handlers/pse.rs index df51110c..fd400e92 100644 --- a/src/api/routes/opds/handlers/pse.rs +++ b/crates/codex-api/src/routes/opds/handlers/pse.rs @@ -1,11 +1,11 @@ use super::super::dto::{OpdsEntry, OpdsFeed, OpdsLink}; -use crate::api::routes::v1::handlers::get_page_image; -use crate::api::{ +use crate::require_permission; +use crate::routes::v1::handlers::get_page_image; +use crate::{ error::ApiError, extractors::{AuthContext, AuthState, FlexibleAuthContext}, permissions::Permission, }; -use crate::require_permission; use axum::{ extract::{Path, State}, http::{HeaderMap, StatusCode, header}, diff --git a/src/api/routes/opds/handlers/search.rs b/crates/codex-api/src/routes/opds/handlers/search.rs similarity index 99% rename from src/api/routes/opds/handlers/search.rs rename to crates/codex-api/src/routes/opds/handlers/search.rs index 624d8fef..77bab314 100644 --- a/src/api/routes/opds/handlers/search.rs +++ b/crates/codex-api/src/routes/opds/handlers/search.rs @@ -1,10 +1,10 @@ use super::super::dto::{OpdsEntry, OpdsFeed, OpdsLink}; -use crate::api::{ +use crate::require_permission; +use crate::{ error::ApiError, extractors::{AuthContext, AuthState}, permissions::Permission, }; -use crate::require_permission; use axum::{ extract::{Query, State}, http::{StatusCode, header}, diff --git a/src/api/routes/opds/mod.rs b/crates/codex-api/src/routes/opds/mod.rs similarity index 96% rename from src/api/routes/opds/mod.rs rename to crates/codex-api/src/routes/opds/mod.rs index c8ddefcc..f26ab2ac 100644 --- a/src/api/routes/opds/mod.rs +++ b/crates/codex-api/src/routes/opds/mod.rs @@ -24,7 +24,7 @@ pub mod dto; pub mod handlers; mod routes; -use crate::api::extractors::AppState; +use crate::extractors::AppState; use axum::Router; use std::sync::Arc; diff --git a/src/api/routes/opds/routes.rs b/crates/codex-api/src/routes/opds/routes.rs similarity index 95% rename from src/api/routes/opds/routes.rs rename to crates/codex-api/src/routes/opds/routes.rs index 69311b06..79b0bda1 100644 --- a/src/api/routes/opds/routes.rs +++ b/crates/codex-api/src/routes/opds/routes.rs @@ -6,7 +6,7 @@ use super::handlers::{ book_page_image, book_pages, library_series, list_libraries, opensearch_descriptor, root_catalog, search, series_books, }; -use crate::api::extractors::AppState; +use crate::extractors::AppState; use axum::{Router, routing::get}; use std::sync::Arc; diff --git a/src/api/routes/opds2/dto/feed.rs b/crates/codex-api/src/routes/opds2/dto/feed.rs similarity index 99% rename from src/api/routes/opds2/dto/feed.rs rename to crates/codex-api/src/routes/opds2/dto/feed.rs index 4431b822..44257037 100644 --- a/src/api/routes/opds2/dto/feed.rs +++ b/crates/codex-api/src/routes/opds2/dto/feed.rs @@ -158,7 +158,7 @@ impl Group { #[cfg(test)] mod tests { use super::*; - use crate::api::routes::opds2::dto::PublicationMetadata; + use crate::routes::opds2::dto::PublicationMetadata; #[test] fn test_navigation_feed_serialization() { diff --git a/src/api/routes/opds2/dto/link.rs b/crates/codex-api/src/routes/opds2/dto/link.rs similarity index 100% rename from src/api/routes/opds2/dto/link.rs rename to crates/codex-api/src/routes/opds2/dto/link.rs diff --git a/src/api/routes/opds2/dto/metadata.rs b/crates/codex-api/src/routes/opds2/dto/metadata.rs similarity index 100% rename from src/api/routes/opds2/dto/metadata.rs rename to crates/codex-api/src/routes/opds2/dto/metadata.rs diff --git a/src/api/routes/opds2/dto/mod.rs b/crates/codex-api/src/routes/opds2/dto/mod.rs similarity index 100% rename from src/api/routes/opds2/dto/mod.rs rename to crates/codex-api/src/routes/opds2/dto/mod.rs diff --git a/src/api/routes/opds2/dto/publication.rs b/crates/codex-api/src/routes/opds2/dto/publication.rs similarity index 99% rename from src/api/routes/opds2/dto/publication.rs rename to crates/codex-api/src/routes/opds2/dto/publication.rs index bcecae95..48365ad1 100644 --- a/src/api/routes/opds2/dto/publication.rs +++ b/crates/codex-api/src/routes/opds2/dto/publication.rs @@ -173,7 +173,7 @@ impl ImageLink { #[cfg(test)] mod tests { use super::*; - use crate::api::routes::opds2::dto::Contributor; + use crate::routes::opds2::dto::Contributor; #[test] fn test_publication_serialization() { diff --git a/src/api/routes/opds2/handlers/catalog.rs b/crates/codex-api/src/routes/opds2/handlers/catalog.rs similarity index 99% rename from src/api/routes/opds2/handlers/catalog.rs rename to crates/codex-api/src/routes/opds2/handlers/catalog.rs index 25f55cc0..35d71e86 100644 --- a/src/api/routes/opds2/handlers/catalog.rs +++ b/crates/codex-api/src/routes/opds2/handlers/catalog.rs @@ -2,13 +2,13 @@ //! //! Handlers for browsing the OPDS 2.0 catalog (JSON-based). -use crate::api::{ +use crate::require_permission; +use crate::{ error::ApiError, extractors::{AuthContext, AuthState}, permissions::Permission, routes::opds::handlers::OpdsPaginationParams, }; -use crate::require_permission; use axum::{ Json, extract::{Path, Query, State}, diff --git a/src/api/routes/opds2/handlers/mod.rs b/crates/codex-api/src/routes/opds2/handlers/mod.rs similarity index 100% rename from src/api/routes/opds2/handlers/mod.rs rename to crates/codex-api/src/routes/opds2/handlers/mod.rs diff --git a/src/api/routes/opds2/handlers/search.rs b/crates/codex-api/src/routes/opds2/handlers/search.rs similarity index 99% rename from src/api/routes/opds2/handlers/search.rs rename to crates/codex-api/src/routes/opds2/handlers/search.rs index f73aeead..85725b92 100644 --- a/src/api/routes/opds2/handlers/search.rs +++ b/crates/codex-api/src/routes/opds2/handlers/search.rs @@ -2,12 +2,12 @@ //! //! Handler for searching books and series via OPDS 2.0. -use crate::api::{ +use crate::require_permission; +use crate::{ error::ApiError, extractors::{AuthContext, AuthState}, permissions::Permission, }; -use crate::require_permission; use axum::extract::{Query, State}; use codex_db::repositories::{ BookMetadataRepository, BookRepository, ReadProgressRepository, SeriesMetadataRepository, diff --git a/src/api/routes/opds2/mod.rs b/crates/codex-api/src/routes/opds2/mod.rs similarity index 96% rename from src/api/routes/opds2/mod.rs rename to crates/codex-api/src/routes/opds2/mod.rs index 712d56dc..898f5332 100644 --- a/src/api/routes/opds2/mod.rs +++ b/crates/codex-api/src/routes/opds2/mod.rs @@ -22,7 +22,7 @@ pub mod dto; pub mod handlers; mod routes; -use crate::api::extractors::AppState; +use crate::extractors::AppState; use axum::Router; use std::sync::Arc; diff --git a/src/api/routes/opds2/routes.rs b/crates/codex-api/src/routes/opds2/routes.rs similarity index 94% rename from src/api/routes/opds2/routes.rs rename to crates/codex-api/src/routes/opds2/routes.rs index aa0b8cf4..b4b83a88 100644 --- a/src/api/routes/opds2/routes.rs +++ b/crates/codex-api/src/routes/opds2/routes.rs @@ -3,7 +3,7 @@ //! Defines all OPDS 2.0 catalog routes (JSON-based). use super::handlers::{libraries, library_series, recent, root, search, series_books}; -use crate::api::extractors::AuthState; +use crate::extractors::AuthState; use axum::{Router, routing::get}; use std::sync::Arc; diff --git a/src/api/routes/v1/dto/api_key.rs b/crates/codex-api/src/routes/v1/dto/api_key.rs similarity index 100% rename from src/api/routes/v1/dto/api_key.rs rename to crates/codex-api/src/routes/v1/dto/api_key.rs diff --git a/src/api/routes/v1/dto/auth.rs b/crates/codex-api/src/routes/v1/dto/auth.rs similarity index 100% rename from src/api/routes/v1/dto/auth.rs rename to crates/codex-api/src/routes/v1/dto/auth.rs diff --git a/src/api/routes/v1/dto/book.rs b/crates/codex-api/src/routes/v1/dto/book.rs similarity index 100% rename from src/api/routes/v1/dto/book.rs rename to crates/codex-api/src/routes/v1/dto/book.rs diff --git a/src/api/routes/v1/dto/bulk_metadata.rs b/crates/codex-api/src/routes/v1/dto/bulk_metadata.rs similarity index 100% rename from src/api/routes/v1/dto/bulk_metadata.rs rename to crates/codex-api/src/routes/v1/dto/bulk_metadata.rs diff --git a/src/api/routes/v1/dto/cleanup.rs b/crates/codex-api/src/routes/v1/dto/cleanup.rs similarity index 100% rename from src/api/routes/v1/dto/cleanup.rs rename to crates/codex-api/src/routes/v1/dto/cleanup.rs diff --git a/src/api/routes/v1/dto/common.rs b/crates/codex-api/src/routes/v1/dto/common.rs similarity index 100% rename from src/api/routes/v1/dto/common.rs rename to crates/codex-api/src/routes/v1/dto/common.rs diff --git a/src/api/routes/v1/dto/duplicates.rs b/crates/codex-api/src/routes/v1/dto/duplicates.rs similarity index 100% rename from src/api/routes/v1/dto/duplicates.rs rename to crates/codex-api/src/routes/v1/dto/duplicates.rs diff --git a/src/api/routes/v1/dto/filter.rs b/crates/codex-api/src/routes/v1/dto/filter.rs similarity index 100% rename from src/api/routes/v1/dto/filter.rs rename to crates/codex-api/src/routes/v1/dto/filter.rs diff --git a/src/api/routes/v1/dto/filter_preset.rs b/crates/codex-api/src/routes/v1/dto/filter_preset.rs similarity index 100% rename from src/api/routes/v1/dto/filter_preset.rs rename to crates/codex-api/src/routes/v1/dto/filter_preset.rs diff --git a/src/api/routes/v1/dto/info.rs b/crates/codex-api/src/routes/v1/dto/info.rs similarity index 100% rename from src/api/routes/v1/dto/info.rs rename to crates/codex-api/src/routes/v1/dto/info.rs diff --git a/src/api/routes/v1/dto/library.rs b/crates/codex-api/src/routes/v1/dto/library.rs similarity index 100% rename from src/api/routes/v1/dto/library.rs rename to crates/codex-api/src/routes/v1/dto/library.rs diff --git a/src/api/routes/v1/dto/library_jobs.rs b/crates/codex-api/src/routes/v1/dto/library_jobs.rs similarity index 99% rename from src/api/routes/v1/dto/library_jobs.rs rename to crates/codex-api/src/routes/v1/dto/library_jobs.rs index 3b5ab335..9e38f4e6 100644 --- a/src/api/routes/v1/dto/library_jobs.rs +++ b/crates/codex-api/src/routes/v1/dto/library_jobs.rs @@ -6,7 +6,7 @@ use std::collections::HashMap; use utoipa::ToSchema; use uuid::Uuid; -use crate::api::routes::v1::dto::patch::PatchValue; +use crate::routes::v1::dto::patch::PatchValue; use codex_services::library_jobs::{LibraryJobConfig, MetadataRefreshJobConfig, RefreshScope}; /// Type-discriminated job config exposed over the wire. diff --git a/src/api/routes/v1/dto/metrics.rs b/crates/codex-api/src/routes/v1/dto/metrics.rs similarity index 100% rename from src/api/routes/v1/dto/metrics.rs rename to crates/codex-api/src/routes/v1/dto/metrics.rs diff --git a/src/api/routes/v1/dto/mod.rs b/crates/codex-api/src/routes/v1/dto/mod.rs similarity index 100% rename from src/api/routes/v1/dto/mod.rs rename to crates/codex-api/src/routes/v1/dto/mod.rs diff --git a/src/api/routes/v1/dto/observability.rs b/crates/codex-api/src/routes/v1/dto/observability.rs similarity index 100% rename from src/api/routes/v1/dto/observability.rs rename to crates/codex-api/src/routes/v1/dto/observability.rs diff --git a/src/api/routes/v1/dto/oidc.rs b/crates/codex-api/src/routes/v1/dto/oidc.rs similarity index 100% rename from src/api/routes/v1/dto/oidc.rs rename to crates/codex-api/src/routes/v1/dto/oidc.rs diff --git a/src/api/routes/v1/dto/page.rs b/crates/codex-api/src/routes/v1/dto/page.rs similarity index 100% rename from src/api/routes/v1/dto/page.rs rename to crates/codex-api/src/routes/v1/dto/page.rs diff --git a/src/api/routes/v1/dto/patch.rs b/crates/codex-api/src/routes/v1/dto/patch.rs similarity index 100% rename from src/api/routes/v1/dto/patch.rs rename to crates/codex-api/src/routes/v1/dto/patch.rs diff --git a/src/api/routes/v1/dto/pdf_cache.rs b/crates/codex-api/src/routes/v1/dto/pdf_cache.rs similarity index 100% rename from src/api/routes/v1/dto/pdf_cache.rs rename to crates/codex-api/src/routes/v1/dto/pdf_cache.rs diff --git a/src/api/routes/v1/dto/plugin_storage.rs b/crates/codex-api/src/routes/v1/dto/plugin_storage.rs similarity index 100% rename from src/api/routes/v1/dto/plugin_storage.rs rename to crates/codex-api/src/routes/v1/dto/plugin_storage.rs diff --git a/src/api/routes/v1/dto/plugins.rs b/crates/codex-api/src/routes/v1/dto/plugins.rs similarity index 100% rename from src/api/routes/v1/dto/plugins.rs rename to crates/codex-api/src/routes/v1/dto/plugins.rs diff --git a/src/api/routes/v1/dto/read_progress.rs b/crates/codex-api/src/routes/v1/dto/read_progress.rs similarity index 100% rename from src/api/routes/v1/dto/read_progress.rs rename to crates/codex-api/src/routes/v1/dto/read_progress.rs diff --git a/src/api/routes/v1/dto/recommendations.rs b/crates/codex-api/src/routes/v1/dto/recommendations.rs similarity index 100% rename from src/api/routes/v1/dto/recommendations.rs rename to crates/codex-api/src/routes/v1/dto/recommendations.rs diff --git a/src/api/routes/v1/dto/release.rs b/crates/codex-api/src/routes/v1/dto/release.rs similarity index 100% rename from src/api/routes/v1/dto/release.rs rename to crates/codex-api/src/routes/v1/dto/release.rs diff --git a/src/api/routes/v1/dto/scan.rs b/crates/codex-api/src/routes/v1/dto/scan.rs similarity index 100% rename from src/api/routes/v1/dto/scan.rs rename to crates/codex-api/src/routes/v1/dto/scan.rs diff --git a/src/api/routes/v1/dto/series.rs b/crates/codex-api/src/routes/v1/dto/series.rs similarity index 100% rename from src/api/routes/v1/dto/series.rs rename to crates/codex-api/src/routes/v1/dto/series.rs diff --git a/src/api/routes/v1/dto/series_export.rs b/crates/codex-api/src/routes/v1/dto/series_export.rs similarity index 100% rename from src/api/routes/v1/dto/series_export.rs rename to crates/codex-api/src/routes/v1/dto/series_export.rs diff --git a/src/api/routes/v1/dto/settings.rs b/crates/codex-api/src/routes/v1/dto/settings.rs similarity index 100% rename from src/api/routes/v1/dto/settings.rs rename to crates/codex-api/src/routes/v1/dto/settings.rs diff --git a/src/api/routes/v1/dto/setup.rs b/crates/codex-api/src/routes/v1/dto/setup.rs similarity index 100% rename from src/api/routes/v1/dto/setup.rs rename to crates/codex-api/src/routes/v1/dto/setup.rs diff --git a/src/api/routes/v1/dto/sharing_tag.rs b/crates/codex-api/src/routes/v1/dto/sharing_tag.rs similarity index 100% rename from src/api/routes/v1/dto/sharing_tag.rs rename to crates/codex-api/src/routes/v1/dto/sharing_tag.rs diff --git a/src/api/routes/v1/dto/task_metrics.rs b/crates/codex-api/src/routes/v1/dto/task_metrics.rs similarity index 100% rename from src/api/routes/v1/dto/task_metrics.rs rename to crates/codex-api/src/routes/v1/dto/task_metrics.rs diff --git a/src/api/routes/v1/dto/tracking.rs b/crates/codex-api/src/routes/v1/dto/tracking.rs similarity index 100% rename from src/api/routes/v1/dto/tracking.rs rename to crates/codex-api/src/routes/v1/dto/tracking.rs diff --git a/src/api/routes/v1/dto/user.rs b/crates/codex-api/src/routes/v1/dto/user.rs similarity index 99% rename from src/api/routes/v1/dto/user.rs rename to crates/codex-api/src/routes/v1/dto/user.rs index d0955091..29cd07ed 100644 --- a/src/api/routes/v1/dto/user.rs +++ b/crates/codex-api/src/routes/v1/dto/user.rs @@ -1,6 +1,6 @@ use super::common::{DEFAULT_PAGE, DEFAULT_PAGE_SIZE}; use super::sharing_tag::UserSharingTagGrantDto; -use crate::api::permissions::UserRole; +use crate::permissions::UserRole; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use utoipa::{IntoParams, ToSchema}; diff --git a/src/api/routes/v1/dto/user_plugins.rs b/crates/codex-api/src/routes/v1/dto/user_plugins.rs similarity index 100% rename from src/api/routes/v1/dto/user_plugins.rs rename to crates/codex-api/src/routes/v1/dto/user_plugins.rs diff --git a/src/api/routes/v1/dto/user_preferences.rs b/crates/codex-api/src/routes/v1/dto/user_preferences.rs similarity index 100% rename from src/api/routes/v1/dto/user_preferences.rs rename to crates/codex-api/src/routes/v1/dto/user_preferences.rs diff --git a/src/api/routes/v1/handlers/api_keys.rs b/crates/codex-api/src/routes/v1/handlers/api_keys.rs similarity index 99% rename from src/api/routes/v1/handlers/api_keys.rs rename to crates/codex-api/src/routes/v1/handlers/api_keys.rs index 63c8bb62..45d2052a 100644 --- a/src/api/routes/v1/handlers/api_keys.rs +++ b/crates/codex-api/src/routes/v1/handlers/api_keys.rs @@ -5,7 +5,7 @@ use super::super::dto::{ }, }; use super::paginated_response; -use crate::api::{ +use crate::{ error::ApiError, extractors::{AuthContext, AuthState}, permissions::{Permission, serialize_permissions}, diff --git a/src/api/routes/v1/handlers/auth.rs b/crates/codex-api/src/routes/v1/handlers/auth.rs similarity index 99% rename from src/api/routes/v1/handlers/auth.rs rename to crates/codex-api/src/routes/v1/handlers/auth.rs index aa0976c2..813833ca 100644 --- a/src/api/routes/v1/handlers/auth.rs +++ b/crates/codex-api/src/routes/v1/handlers/auth.rs @@ -3,7 +3,7 @@ use super::super::dto::{ ResendVerificationRequest, ResendVerificationResponse, TokenPair, UserInfo, VerifyEmailRequest, VerifyEmailResponse, }; -use crate::api::{ +use crate::{ error::ApiError, extractors::{AuthContext, AuthState, ClientInfo, FlexibleAuthContext}, permissions::UserRole, // Used for creating users with default role diff --git a/src/api/routes/v1/handlers/books.rs b/crates/codex-api/src/routes/v1/handlers/books.rs similarity index 99% rename from src/api/routes/v1/handlers/books.rs rename to crates/codex-api/src/routes/v1/handlers/books.rs index a242f05b..62084518 100644 --- a/src/api/routes/v1/handlers/books.rs +++ b/crates/codex-api/src/routes/v1/handlers/books.rs @@ -13,12 +13,12 @@ use super::super::dto::{ series::{GenreDto, GenreListResponse, TagDto, TagListResponse}, }; use super::paginated_response; -use crate::api::{ +use crate::require_permission; +use crate::{ error::ApiError, extractors::{AuthContext, AuthState, ContentFilter, FlexibleAuthContext}, permissions::Permission, }; -use crate::require_permission; use axum::{ Json, body::Body, @@ -219,14 +219,12 @@ pub async fn books_to_dtos( .map_err(|e| ApiError::Internal(format!("Failed to fetch book metadata: {}", e)))?; let library_map = libraries_result .map_err(|e| ApiError::Internal(format!("Failed to fetch libraries: {}", e)))?; - let progress_map: HashMap< - Uuid, - crate::api::routes::v1::dto::read_progress::ReadProgressResponse, - > = progress_result - .map_err(|e| ApiError::Internal(format!("Failed to fetch read progress: {}", e)))? - .into_iter() - .map(|(book_id, model)| (book_id, model.into())) - .collect(); + let progress_map: HashMap<Uuid, crate::routes::v1::dto::read_progress::ReadProgressResponse> = + progress_result + .map_err(|e| ApiError::Internal(format!("Failed to fetch read progress: {}", e)))? + .into_iter() + .map(|(book_id, model)| (book_id, model.into())) + .collect(); // Convert books to DTOs let dtos = books @@ -858,8 +856,8 @@ pub async fn list_books_filtered( Query(pagination): Query<ListPaginationParams>, Json(request): Json<BookListRequest>, ) -> Result<Response, ApiError> { - use crate::api::routes::v1::dto::book::{BookSortField, BookSortParam}; - use crate::api::routes::v1::dto::series::SortDirection; + use crate::routes::v1::dto::book::{BookSortField, BookSortParam}; + use crate::routes::v1::dto::series::SortDirection; require_permission!(auth, Permission::BooksRead)?; @@ -2131,7 +2129,7 @@ pub async fn get_book_file( // Book Metadata Endpoints // ============================================================================ -use crate::api::routes::v1::dto::{ +use crate::routes::v1::dto::{ BookMetadataResponse, PatchBookMetadataRequest, ReplaceBookMetadataRequest, }; use chrono::Utc; @@ -3097,7 +3095,7 @@ pub async fn patch_book_metadata( // Book Metadata Lock Endpoints // ============================================================================ -use crate::api::routes::v1::dto::{ +use crate::routes::v1::dto::{ BookUpdateResponse, PatchBookRequest, UpdateBookMetadataLocksRequest, }; diff --git a/src/api/routes/v1/handlers/bulk.rs b/crates/codex-api/src/routes/v1/handlers/bulk.rs similarity index 99% rename from src/api/routes/v1/handlers/bulk.rs rename to crates/codex-api/src/routes/v1/handlers/bulk.rs index e9756e1e..976f7269 100644 --- a/src/api/routes/v1/handlers/bulk.rs +++ b/crates/codex-api/src/routes/v1/handlers/bulk.rs @@ -9,8 +9,8 @@ use super::super::dto::{ BulkGenerateSeriesThumbnailsRequest, BulkMetadataResetResponse, BulkRenumberSeriesRequest, BulkReprocessSeriesTitlesRequest, BulkSeriesRequest, BulkTaskResponse, MarkReadResponse, }; -use crate::api::{AppState, error::ApiError, extractors::AuthContext, permissions::Permission}; use crate::require_permission; +use crate::{AppState, error::ApiError, extractors::AuthContext, permissions::Permission}; use axum::{Json, extract::State}; use chrono::Utc; use codex_db::repositories::{ diff --git a/src/api/routes/v1/handlers/bulk_metadata.rs b/crates/codex-api/src/routes/v1/handlers/bulk_metadata.rs similarity index 99% rename from src/api/routes/v1/handlers/bulk_metadata.rs rename to crates/codex-api/src/routes/v1/handlers/bulk_metadata.rs index b464ab0c..2b5723c1 100644 --- a/src/api/routes/v1/handlers/bulk_metadata.rs +++ b/crates/codex-api/src/routes/v1/handlers/bulk_metadata.rs @@ -4,8 +4,8 @@ //! and bulk metadata lock toggling for series and books. use super::super::dto::bulk_metadata::*; -use crate::api::{AppState, error::ApiError, extractors::AuthContext, permissions::Permission}; use crate::require_permission; +use crate::{AppState, error::ApiError, extractors::AuthContext, permissions::Permission}; use axum::{Json, extract::State}; use chrono::Utc; use codex_db::entities::{book_metadata, series_metadata}; diff --git a/src/api/routes/v1/handlers/cleanup.rs b/crates/codex-api/src/routes/v1/handlers/cleanup.rs similarity index 99% rename from src/api/routes/v1/handlers/cleanup.rs rename to crates/codex-api/src/routes/v1/handlers/cleanup.rs index 5018f648..db67e4ad 100644 --- a/src/api/routes/v1/handlers/cleanup.rs +++ b/crates/codex-api/src/routes/v1/handlers/cleanup.rs @@ -12,12 +12,12 @@ use std::sync::Arc; use super::super::dto::{ CleanupResultDto, OrphanStatsDto, OrphanStatsQuery, OrphanedFileDto, TriggerCleanupResponse, }; -use crate::api::{ +use crate::require_permission; +use crate::{ error::ApiError, extractors::{AppState, AuthContext}, permissions::Permission, }; -use crate::require_permission; use codex_db::repositories::{BookRepository, SeriesRepository, TaskRepository}; use codex_services::file_cleanup::OrphanedFileType; use codex_tasks::types::TaskType; diff --git a/src/api/routes/v1/handlers/duplicates.rs b/crates/codex-api/src/routes/v1/handlers/duplicates.rs similarity index 99% rename from src/api/routes/v1/handlers/duplicates.rs rename to crates/codex-api/src/routes/v1/handlers/duplicates.rs index 03170fcc..55a4fb69 100644 --- a/src/api/routes/v1/handlers/duplicates.rs +++ b/crates/codex-api/src/routes/v1/handlers/duplicates.rs @@ -16,7 +16,7 @@ use super::super::dto::{ ListSeriesDuplicatesResponse, SeriesDuplicateGroup, SeriesDuplicateMember, TriggerDuplicateScanResponse, }; -use crate::api::{AppState, error::ApiError, extractors::AuthContext, permissions::Permission}; +use crate::{AppState, error::ApiError, extractors::AuthContext, permissions::Permission}; use codex_db::entities::series_duplicates::{MATCH_TYPE_EXTERNAL_ID, MATCH_TYPE_TITLE}; use codex_db::repositories::{ BookDuplicatesRepository, SeriesDuplicatesRepository, TaskRepository, diff --git a/src/api/routes/v1/handlers/events.rs b/crates/codex-api/src/routes/v1/handlers/events.rs similarity index 99% rename from src/api/routes/v1/handlers/events.rs rename to crates/codex-api/src/routes/v1/handlers/events.rs index 2cffe089..98a5dea8 100644 --- a/src/api/routes/v1/handlers/events.rs +++ b/crates/codex-api/src/routes/v1/handlers/events.rs @@ -1,4 +1,4 @@ -use crate::api::{AppState, error::ApiError, extractors::AuthContext, permissions::Permission}; +use crate::{AppState, error::ApiError, extractors::AuthContext, permissions::Permission}; use axum::{ extract::State, response::sse::{Event, KeepAlive, Sse}, diff --git a/src/api/routes/v1/handlers/filesystem.rs b/crates/codex-api/src/routes/v1/handlers/filesystem.rs similarity index 98% rename from src/api/routes/v1/handlers/filesystem.rs rename to crates/codex-api/src/routes/v1/handlers/filesystem.rs index 7084b941..4c445449 100644 --- a/src/api/routes/v1/handlers/filesystem.rs +++ b/crates/codex-api/src/routes/v1/handlers/filesystem.rs @@ -1,6 +1,6 @@ -use crate::api::error::{ApiError, ErrorResponse}; -use crate::api::extractors::{AppState, AuthContext}; -use crate::api::permissions::Permission; +use crate::error::{ApiError, ErrorResponse}; +use crate::extractors::{AppState, AuthContext}; +use crate::permissions::Permission; use crate::require_permission; use axum::{ Json, diff --git a/src/api/routes/v1/handlers/filter_presets.rs b/crates/codex-api/src/routes/v1/handlers/filter_presets.rs similarity index 98% rename from src/api/routes/v1/handlers/filter_presets.rs rename to crates/codex-api/src/routes/v1/handlers/filter_presets.rs index 80d5ad3a..f91466ca 100644 --- a/src/api/routes/v1/handlers/filter_presets.rs +++ b/crates/codex-api/src/routes/v1/handlers/filter_presets.rs @@ -12,8 +12,8 @@ use sea_orm::DbErr; use std::sync::Arc; use uuid::Uuid; -use crate::api::error::ApiError; -use crate::api::extractors::auth::{AppState, AuthContext}; +use crate::error::ApiError; +use crate::extractors::auth::{AppState, AuthContext}; use codex_db::repositories::{ FilterPresetRepository, ListFilterPresetsQuery as RepoListQuery, UpdateFilterPreset, }; diff --git a/src/api/routes/v1/handlers/health.rs b/crates/codex-api/src/routes/v1/handlers/health.rs similarity index 100% rename from src/api/routes/v1/handlers/health.rs rename to crates/codex-api/src/routes/v1/handlers/health.rs diff --git a/src/api/routes/v1/handlers/info.rs b/crates/codex-api/src/routes/v1/handlers/info.rs similarity index 60% rename from src/api/routes/v1/handlers/info.rs rename to crates/codex-api/src/routes/v1/handlers/info.rs index c5130b03..91d19e5b 100644 --- a/src/api/routes/v1/handlers/info.rs +++ b/crates/codex-api/src/routes/v1/handlers/info.rs @@ -1,8 +1,11 @@ //! Application info handler -use axum::Json; +use std::sync::Arc; + +use axum::{Json, extract::State}; use super::super::dto::AppInfoDto; +use crate::extractors::AppState; /// Get application information /// @@ -16,9 +19,9 @@ use super::super::dto::AppInfoDto; ), tag = "Info" )] -pub async fn get_app_info() -> Json<AppInfoDto> { +pub async fn get_app_info(State(state): State<Arc<AppState>>) -> Json<AppInfoDto> { Json(AppInfoDto { - version: env!("CARGO_PKG_VERSION").to_string(), - name: env!("CARGO_PKG_NAME").to_string(), + version: state.app_version.to_string(), + name: state.app_name.to_string(), }) } diff --git a/src/api/routes/v1/handlers/libraries.rs b/crates/codex-api/src/routes/v1/handlers/libraries.rs similarity index 99% rename from src/api/routes/v1/handlers/libraries.rs rename to crates/codex-api/src/routes/v1/handlers/libraries.rs index aaa4cc1e..739c5c38 100644 --- a/src/api/routes/v1/handlers/libraries.rs +++ b/crates/codex-api/src/routes/v1/handlers/libraries.rs @@ -6,12 +6,12 @@ use super::super::dto::{ }, }; use super::paginated_response; -use crate::api::{ +use crate::require_permission; +use crate::{ error::ApiError, extractors::{AuthContext, AuthState}, permissions::Permission, }; -use crate::require_permission; use axum::{ Json, extract::{Path, Query, State}, diff --git a/src/api/routes/v1/handlers/library_jobs.rs b/crates/codex-api/src/routes/v1/handlers/library_jobs.rs similarity index 99% rename from src/api/routes/v1/handlers/library_jobs.rs rename to crates/codex-api/src/routes/v1/handlers/library_jobs.rs index 04e7b018..3241f0e7 100644 --- a/src/api/routes/v1/handlers/library_jobs.rs +++ b/crates/codex-api/src/routes/v1/handlers/library_jobs.rs @@ -9,12 +9,12 @@ use axum::{ use std::sync::Arc; use uuid::Uuid; -use crate::api::{ +use crate::require_permission; +use crate::{ error::ApiError, extractors::{AppState, AuthContext}, permissions::Permission, }; -use crate::require_permission; use codex_db::entities::library_jobs; use codex_db::repositories::{ CreateLibraryJobParams, LibraryJobRepository, LibraryRepository, SeriesRepository, diff --git a/src/api/routes/v1/handlers/metrics.rs b/crates/codex-api/src/routes/v1/handlers/metrics.rs similarity index 98% rename from src/api/routes/v1/handlers/metrics.rs rename to crates/codex-api/src/routes/v1/handlers/metrics.rs index 68226ea2..ba42f614 100644 --- a/src/api/routes/v1/handlers/metrics.rs +++ b/crates/codex-api/src/routes/v1/handlers/metrics.rs @@ -6,7 +6,7 @@ use super::super::dto::{ LibraryMetricsDto, MetricsDto, PluginMethodMetricsDto, PluginMetricsDto, PluginMetricsResponse, PluginMetricsSummaryDto, }; -use crate::api::{AppState, error::ApiError, extractors::AuthContext, permissions::Permission}; +use crate::{AppState, error::ApiError, extractors::AuthContext, permissions::Permission}; use codex_db::repositories::MetricsRepository; /// Get inventory metrics (library/book counts) diff --git a/src/api/routes/v1/handlers/mod.rs b/crates/codex-api/src/routes/v1/handlers/mod.rs similarity index 100% rename from src/api/routes/v1/handlers/mod.rs rename to crates/codex-api/src/routes/v1/handlers/mod.rs diff --git a/src/api/routes/v1/handlers/observability.rs b/crates/codex-api/src/routes/v1/handlers/observability.rs similarity index 99% rename from src/api/routes/v1/handlers/observability.rs rename to crates/codex-api/src/routes/v1/handlers/observability.rs index 26ad3a5d..ed99401b 100644 --- a/src/api/routes/v1/handlers/observability.rs +++ b/crates/codex-api/src/routes/v1/handlers/observability.rs @@ -19,7 +19,7 @@ use axum::{ }; use tokio::sync::OnceCell; -use crate::api::{ +use crate::{ error::ApiError, extractors::{AppState, FlexibleAuthContext}, }; diff --git a/src/api/routes/v1/handlers/oidc.rs b/crates/codex-api/src/routes/v1/handlers/oidc.rs similarity index 99% rename from src/api/routes/v1/handlers/oidc.rs rename to crates/codex-api/src/routes/v1/handlers/oidc.rs index 7751d944..57eaaea5 100644 --- a/src/api/routes/v1/handlers/oidc.rs +++ b/crates/codex-api/src/routes/v1/handlers/oidc.rs @@ -8,7 +8,7 @@ use super::super::dto::{ OidcProvidersResponse, UserInfo, }; use super::auth::build_auth_cookie; -use crate::api::{error::ApiError, extractors::AppState, permissions::UserRole}; +use crate::{error::ApiError, extractors::AppState, permissions::UserRole}; use axum::{ Json, extract::{Path, Query, State}, diff --git a/src/api/routes/v1/handlers/pages.rs b/crates/codex-api/src/routes/v1/handlers/pages.rs similarity index 99% rename from src/api/routes/v1/handlers/pages.rs rename to crates/codex-api/src/routes/v1/handlers/pages.rs index faf7384d..65d2dcc0 100644 --- a/src/api/routes/v1/handlers/pages.rs +++ b/crates/codex-api/src/routes/v1/handlers/pages.rs @@ -1,9 +1,9 @@ -use crate::api::{ +use crate::require_permission; +use crate::{ error::ApiError, extractors::{AuthState, FlexibleAuthContext}, permissions::Permission, }; -use crate::require_permission; use axum::{ body::Body, extract::{Path, State}, @@ -21,7 +21,7 @@ use uuid::Uuid; /// Placeholder SVG for thumbnails that are being generated or don't exist /// This is a simple gray rectangle with a book icon, loaded from assets at compile time -const PLACEHOLDER_SVG: &[u8] = include_bytes!("../../../../../assets/placeholder-cover.svg"); +const PLACEHOLDER_SVG: &[u8] = include_bytes!("../../../../../../assets/placeholder-cover.svg"); /// Get page image from a book /// diff --git a/src/api/routes/v1/handlers/pdf_cache.rs b/crates/codex-api/src/routes/v1/handlers/pdf_cache.rs similarity index 99% rename from src/api/routes/v1/handlers/pdf_cache.rs rename to crates/codex-api/src/routes/v1/handlers/pdf_cache.rs index 77bc606f..3382d3a0 100644 --- a/src/api/routes/v1/handlers/pdf_cache.rs +++ b/crates/codex-api/src/routes/v1/handlers/pdf_cache.rs @@ -15,12 +15,12 @@ use super::super::dto::{ PdfCacheCleanupResultDto, PdfCacheStatsDto, PdfHandleCacheClearResultDto, PdfHandleCacheStatsDto, PdfPageCacheStatsDto, TriggerPdfCacheCleanupResponse, }; -use crate::api::{ +use crate::require_permission; +use crate::{ error::ApiError, extractors::{AppState, AuthContext}, permissions::Permission, }; -use crate::require_permission; use codex_db::repositories::TaskRepository; use codex_tasks::types::TaskType; diff --git a/src/api/routes/v1/handlers/plugin_actions.rs b/crates/codex-api/src/routes/v1/handlers/plugin_actions.rs similarity index 99% rename from src/api/routes/v1/handlers/plugin_actions.rs rename to crates/codex-api/src/routes/v1/handlers/plugin_actions.rs index c6855f79..80557826 100644 --- a/src/api/routes/v1/handlers/plugin_actions.rs +++ b/crates/codex-api/src/routes/v1/handlers/plugin_actions.rs @@ -19,7 +19,7 @@ use super::super::dto::{ PluginActionRequest, PluginActionsResponse, PluginSearchResponse, PluginSearchResultDto, PreviewSummary, SearchTitleResponse, SkippedField, parse_scope, }; -use crate::api::{AppState, error::ApiError, extractors::AuthContext, permissions::Permission}; +use crate::{AppState, error::ApiError, extractors::AuthContext, permissions::Permission}; use axum::{ Json, extract::{Path, Query, State}, diff --git a/src/api/routes/v1/handlers/plugin_storage.rs b/crates/codex-api/src/routes/v1/handlers/plugin_storage.rs similarity index 99% rename from src/api/routes/v1/handlers/plugin_storage.rs rename to crates/codex-api/src/routes/v1/handlers/plugin_storage.rs index 13e33948..3b7b1811 100644 --- a/src/api/routes/v1/handlers/plugin_storage.rs +++ b/crates/codex-api/src/routes/v1/handlers/plugin_storage.rs @@ -10,12 +10,12 @@ use axum::{ use std::sync::Arc; use super::super::dto::{AllPluginStorageStatsDto, PluginCleanupResultDto, PluginStorageStatsDto}; -use crate::api::{ +use crate::require_permission; +use crate::{ error::ApiError, extractors::{AppState, AuthContext}, permissions::Permission, }; -use crate::require_permission; /// Get storage statistics for all plugins /// diff --git a/src/api/routes/v1/handlers/plugins.rs b/crates/codex-api/src/routes/v1/handlers/plugins.rs similarity index 99% rename from src/api/routes/v1/handlers/plugins.rs rename to crates/codex-api/src/routes/v1/handlers/plugins.rs index 957fdd3d..2ee94457 100644 --- a/src/api/routes/v1/handlers/plugins.rs +++ b/crates/codex-api/src/routes/v1/handlers/plugins.rs @@ -10,7 +10,7 @@ use super::super::dto::{ available_credential_delivery_methods, available_permissions, available_scopes, parse_permission, parse_scope, }; -use crate::api::{AppState, error::ApiError, extractors::AuthContext, permissions::Permission}; +use crate::{AppState, error::ApiError, extractors::AuthContext, permissions::Permission}; use axum::{ Json, extract::{Path, State}, diff --git a/src/api/routes/v1/handlers/read_progress.rs b/crates/codex-api/src/routes/v1/handlers/read_progress.rs similarity index 99% rename from src/api/routes/v1/handlers/read_progress.rs rename to crates/codex-api/src/routes/v1/handlers/read_progress.rs index 1de66e5a..25c7b218 100644 --- a/src/api/routes/v1/handlers/read_progress.rs +++ b/crates/codex-api/src/routes/v1/handlers/read_progress.rs @@ -1,7 +1,7 @@ use super::super::dto::{ MarkReadResponse, ReadProgressListResponse, ReadProgressResponse, UpdateProgressRequest, }; -use crate::api::{AppState, error::ApiError, extractors::AuthContext, permissions::Permission}; +use crate::{AppState, error::ApiError, extractors::AuthContext, permissions::Permission}; use axum::{ Json, extract::{Path, State}, diff --git a/src/api/routes/v1/handlers/recommendations.rs b/crates/codex-api/src/routes/v1/handlers/recommendations.rs similarity index 99% rename from src/api/routes/v1/handlers/recommendations.rs rename to crates/codex-api/src/routes/v1/handlers/recommendations.rs index 43f9a56b..3e273365 100644 --- a/src/api/routes/v1/handlers/recommendations.rs +++ b/crates/codex-api/src/routes/v1/handlers/recommendations.rs @@ -8,8 +8,8 @@ use super::super::dto::recommendations::{ DismissRecommendationRequest, DismissRecommendationResponse, RecommendationDto, RecommendationsRefreshResponse, RecommendationsResponse, }; -use crate::api::extractors::auth::AuthContext; -use crate::api::{error::ApiError, extractors::AppState}; +use crate::extractors::auth::AuthContext; +use crate::{error::ApiError, extractors::AppState}; use axum::{ Json, extract::{Path, State}, @@ -488,7 +488,7 @@ pub async fn dismiss_recommendation( #[cfg(test)] mod tests { use super::*; - use crate::api::error::ApiError; + use crate::error::ApiError; use codex_services::plugin::handle::PluginError; use codex_services::plugin::process::ProcessError; use codex_services::plugin::recommendations::Recommendation; diff --git a/src/api/routes/v1/handlers/releases.rs b/crates/codex-api/src/routes/v1/handlers/releases.rs similarity index 99% rename from src/api/routes/v1/handlers/releases.rs rename to crates/codex-api/src/routes/v1/handlers/releases.rs index a8184667..9702c25e 100644 --- a/src/api/routes/v1/handlers/releases.rs +++ b/crates/codex-api/src/routes/v1/handlers/releases.rs @@ -35,7 +35,7 @@ use super::super::dto::release::{ UpdateReleaseSourceRequest, }; use super::paginated_response; -use crate::api::{ +use crate::{ error::ApiError, extractors::{AuthContext, AuthState}, permissions::Permission, diff --git a/src/api/routes/v1/handlers/scan.rs b/crates/codex-api/src/routes/v1/handlers/scan.rs similarity index 99% rename from src/api/routes/v1/handlers/scan.rs rename to crates/codex-api/src/routes/v1/handlers/scan.rs index 096444e1..37d1daed 100644 --- a/src/api/routes/v1/handlers/scan.rs +++ b/crates/codex-api/src/routes/v1/handlers/scan.rs @@ -13,7 +13,7 @@ use uuid::Uuid; use super::super::dto::{ScanStatusDto, TriggerScanQuery}; use super::task_queue::CreateTaskResponse; -use crate::api::{AppState, error::ApiError, extractors::AuthContext, permissions::Permission}; +use crate::{AppState, error::ApiError, extractors::AuthContext, permissions::Permission}; use codex_db::repositories::{BookRepository, LibraryRepository, SeriesRepository, TaskRepository}; use codex_scanner::ScanMode; use codex_tasks::types::TaskType; diff --git a/src/api/routes/v1/handlers/series.rs b/crates/codex-api/src/routes/v1/handlers/series.rs similarity index 99% rename from src/api/routes/v1/handlers/series.rs rename to crates/codex-api/src/routes/v1/handlers/series.rs index 11409366..9e527900 100644 --- a/src/api/routes/v1/handlers/series.rs +++ b/crates/codex-api/src/routes/v1/handlers/series.rs @@ -21,12 +21,12 @@ use super::super::dto::{ }, }; use super::paginated_response; -use crate::api::{ +use crate::require_permission; +use crate::{ error::ApiError, extractors::{AuthContext, AuthState, ContentFilter, FlexibleAuthContext}, permissions::Permission, }; -use crate::require_permission; use axum::{ Json, body::Body, @@ -62,7 +62,7 @@ use zip::write::SimpleFileOptions; /// Placeholder SVG for series thumbnails that are being generated or don't exist /// This is a simple gray rectangle with a book icon, loaded from assets at compile time -const PLACEHOLDER_SVG: &[u8] = include_bytes!("../../../../../assets/placeholder-cover.svg"); +const PLACEHOLDER_SVG: &[u8] = include_bytes!("../../../../../../assets/placeholder-cover.svg"); /// Query parameters for listing books in a series #[derive(Debug, Deserialize, utoipa::IntoParams)] @@ -1182,7 +1182,7 @@ pub async fn list_series_filtered( Query(pagination): Query<ListPaginationParams>, Json(request): Json<SeriesListRequest>, ) -> Result<Response, ApiError> { - use crate::api::routes::v1::dto::series::{SeriesSortField, SortDirection}; + use crate::routes::v1::dto::series::{SeriesSortField, SortDirection}; use codex_services::FilterService; use std::collections::HashSet; diff --git a/src/api/routes/v1/handlers/series_exports.rs b/crates/codex-api/src/routes/v1/handlers/series_exports.rs similarity index 99% rename from src/api/routes/v1/handlers/series_exports.rs rename to crates/codex-api/src/routes/v1/handlers/series_exports.rs index b4a94023..8906da3a 100644 --- a/src/api/routes/v1/handlers/series_exports.rs +++ b/crates/codex-api/src/routes/v1/handlers/series_exports.rs @@ -10,8 +10,8 @@ use chrono::{Duration, Utc}; use std::sync::Arc; use uuid::Uuid; -use crate::api::error::ApiError; -use crate::api::extractors::auth::{AppState, AuthContext}; +use crate::error::ApiError; +use crate::extractors::auth::{AppState, AuthContext}; use codex_db::repositories::{SeriesExportRepository, TaskRepository}; use codex_services::book_export_collector::BookExportField; use codex_services::series_export_collector::ExportField; diff --git a/src/api/routes/v1/handlers/settings.rs b/crates/codex-api/src/routes/v1/handlers/settings.rs similarity index 98% rename from src/api/routes/v1/handlers/settings.rs rename to crates/codex-api/src/routes/v1/handlers/settings.rs index cb354993..b16529c6 100644 --- a/src/api/routes/v1/handlers/settings.rs +++ b/crates/codex-api/src/routes/v1/handlers/settings.rs @@ -2,12 +2,12 @@ use super::super::dto::{ BrandingSettingsDto, BulkUpdateSettingsRequest, HistoryQuery, ListSettingsQuery, PublicSettingDto, SettingDto, SettingHistoryDto, UpdateSettingRequest, }; -use crate::api::{ +use crate::require_permission; +use crate::{ error::ApiError, extractors::{AuthContext, AuthState}, permissions::Permission, }; -use crate::require_permission; use axum::{ Json, extract::{Path, Query, State}, @@ -148,7 +148,7 @@ pub async fn get_setting( pub async fn update_setting( State(state): State<Arc<AuthState>>, auth: AuthContext, - client_info: crate::api::extractors::ClientInfo, + client_info: crate::extractors::ClientInfo, Path(setting_key): Path<String>, Json(request): Json<UpdateSettingRequest>, ) -> Result<Json<SettingDto>, ApiError> { @@ -228,7 +228,7 @@ pub async fn update_setting( pub async fn bulk_update_settings( State(state): State<Arc<AuthState>>, auth: AuthContext, - client_info: crate::api::extractors::ClientInfo, + client_info: crate::extractors::ClientInfo, Json(request): Json<BulkUpdateSettingsRequest>, ) -> Result<Json<Vec<SettingDto>>, ApiError> { require_permission!(auth, Permission::SystemAdmin)?; @@ -305,7 +305,7 @@ pub async fn bulk_update_settings( pub async fn reset_setting( State(state): State<Arc<AuthState>>, auth: AuthContext, - client_info: crate::api::extractors::ClientInfo, + client_info: crate::extractors::ClientInfo, Path(setting_key): Path<String>, ) -> Result<Json<SettingDto>, ApiError> { require_permission!(auth, Permission::SystemAdmin)?; diff --git a/src/api/routes/v1/handlers/setup.rs b/crates/codex-api/src/routes/v1/handlers/setup.rs similarity index 99% rename from src/api/routes/v1/handlers/setup.rs rename to crates/codex-api/src/routes/v1/handlers/setup.rs index 8b416cdd..9fa6f103 100644 --- a/src/api/routes/v1/handlers/setup.rs +++ b/crates/codex-api/src/routes/v1/handlers/setup.rs @@ -3,12 +3,12 @@ use super::super::dto::{ InitializeSetupResponse, SetupStatusResponse, UserInfo, }; use super::auth::build_auth_cookie; -use crate::api::{ +use crate::require_permission; +use crate::{ error::ApiError, extractors::{AuthContext, AuthState}, permissions::Permission, }; -use crate::require_permission; use axum::{ Json, extract::State, @@ -158,7 +158,7 @@ pub async fn initialize_setup( .map_err(|e| ApiError::Internal(format!("Password hashing error: {}", e)))?; // Create first admin user with Admin role - use crate::api::permissions::UserRole; + use crate::permissions::UserRole; let new_user = users::Model { id: Uuid::new_v4(), diff --git a/src/api/routes/v1/handlers/sharing_tags.rs b/crates/codex-api/src/routes/v1/handlers/sharing_tags.rs similarity index 99% rename from src/api/routes/v1/handlers/sharing_tags.rs rename to crates/codex-api/src/routes/v1/handlers/sharing_tags.rs index d2961bf1..05477069 100644 --- a/src/api/routes/v1/handlers/sharing_tags.rs +++ b/crates/codex-api/src/routes/v1/handlers/sharing_tags.rs @@ -12,7 +12,7 @@ use super::super::dto::{ }, }; use super::paginated_response; -use crate::api::{ +use crate::{ error::ApiError, extractors::{AuthContext, AuthState}, permissions::Permission, diff --git a/src/api/routes/v1/handlers/task_metrics.rs b/crates/codex-api/src/routes/v1/handlers/task_metrics.rs similarity index 98% rename from src/api/routes/v1/handlers/task_metrics.rs rename to crates/codex-api/src/routes/v1/handlers/task_metrics.rs index 3c37535f..cee3d7b2 100644 --- a/src/api/routes/v1/handlers/task_metrics.rs +++ b/crates/codex-api/src/routes/v1/handlers/task_metrics.rs @@ -7,7 +7,7 @@ use super::super::dto::{ TaskMetricsHistoryQuery, TaskMetricsHistoryResponse, TaskMetricsResponse, TaskMetricsSummaryDto, TaskTypeMetricsDto, }; -use crate::api::{AppState, error::ApiError, extractors::AuthContext, permissions::Permission}; +use crate::{AppState, error::ApiError, extractors::AuthContext, permissions::Permission}; use codex_db::repositories::TaskRepository; /// Get current task metrics diff --git a/src/api/routes/v1/handlers/task_queue.rs b/crates/codex-api/src/routes/v1/handlers/task_queue.rs similarity index 99% rename from src/api/routes/v1/handlers/task_queue.rs rename to crates/codex-api/src/routes/v1/handlers/task_queue.rs index cc347547..d2b7ba6b 100644 --- a/src/api/routes/v1/handlers/task_queue.rs +++ b/crates/codex-api/src/routes/v1/handlers/task_queue.rs @@ -8,8 +8,8 @@ use std::sync::Arc; use utoipa::ToSchema; use uuid::Uuid; -use crate::api::{error::ApiError, extractors::AuthContext, permissions::Permission}; use crate::require_permission; +use crate::{error::ApiError, extractors::AuthContext, permissions::Permission}; use codex_db::repositories::{ LibraryRepository, SeriesMetadataRepository, SeriesRepository, TaskRepository, }; @@ -19,7 +19,7 @@ use super::super::dto::series::{ EnqueueReprocessTitleRequest, EnqueueReprocessTitleResponse, ReprocessSeriesTitlesRequest, ReprocessTitleRequest, }; -use crate::api::AppState; +use crate::AppState; // DTOs diff --git a/src/api/routes/v1/handlers/tracking.rs b/crates/codex-api/src/routes/v1/handlers/tracking.rs similarity index 99% rename from src/api/routes/v1/handlers/tracking.rs rename to crates/codex-api/src/routes/v1/handlers/tracking.rs index aaa3fa79..b50148e6 100644 --- a/src/api/routes/v1/handlers/tracking.rs +++ b/crates/codex-api/src/routes/v1/handlers/tracking.rs @@ -20,12 +20,12 @@ use super::super::dto::tracking::{ CreateSeriesAliasRequest, SeriesAliasDto, SeriesAliasListResponse, SeriesTrackingDto, UpdateSeriesTrackingRequest, }; -use crate::api::{ +use crate::require_permission; +use crate::{ error::ApiError, extractors::{AuthContext, AuthState}, permissions::Permission, }; -use crate::require_permission; use codex_db::entities::series_aliases::alias_source; use codex_db::repositories::{ SeriesAliasRepository, SeriesRepository, SeriesTrackingRepository, TrackingUpdate, diff --git a/src/api/routes/v1/handlers/user_plugins.rs b/crates/codex-api/src/routes/v1/handlers/user_plugins.rs similarity index 99% rename from src/api/routes/v1/handlers/user_plugins.rs rename to crates/codex-api/src/routes/v1/handlers/user_plugins.rs index 7a560d39..98afb950 100644 --- a/src/api/routes/v1/handlers/user_plugins.rs +++ b/crates/codex-api/src/routes/v1/handlers/user_plugins.rs @@ -11,8 +11,8 @@ use super::super::dto::user_plugins::{ UserPluginCapabilitiesDto, UserPluginDto, UserPluginTaskDto, UserPluginTasksQuery, UserPluginsListResponse, }; -use crate::api::extractors::auth::AuthContext; -use crate::api::{error::ApiError, extractors::AppState}; +use crate::extractors::auth::AuthContext; +use crate::{error::ApiError, extractors::AppState}; use axum::{ Json, extract::{Path, Query, State}, diff --git a/src/api/routes/v1/handlers/user_preferences.rs b/crates/codex-api/src/routes/v1/handlers/user_preferences.rs similarity index 99% rename from src/api/routes/v1/handlers/user_preferences.rs rename to crates/codex-api/src/routes/v1/handlers/user_preferences.rs index 74ae3bc8..43d3a52e 100644 --- a/src/api/routes/v1/handlers/user_preferences.rs +++ b/crates/codex-api/src/routes/v1/handlers/user_preferences.rs @@ -4,7 +4,7 @@ use super::super::dto::{ BulkSetPreferencesRequest, DeletePreferenceResponse, SetPreferenceRequest, SetPreferencesResponse, UserPreferenceDto, UserPreferencesResponse, }; -use crate::api::{AppState, error::ApiError, extractors::AuthContext}; +use crate::{AppState, error::ApiError, extractors::AuthContext}; use axum::{ Json, extract::{Path, State}, diff --git a/src/api/routes/v1/handlers/users.rs b/crates/codex-api/src/routes/v1/handlers/users.rs similarity index 99% rename from src/api/routes/v1/handlers/users.rs rename to crates/codex-api/src/routes/v1/handlers/users.rs index 4dbe9bf1..c93e1662 100644 --- a/src/api/routes/v1/handlers/users.rs +++ b/crates/codex-api/src/routes/v1/handlers/users.rs @@ -3,12 +3,12 @@ use super::super::dto::{ UserListParams, UserSharingTagGrantDto, common::PaginationLinkBuilder, }; use super::paginated_response; -use crate::api::{ +use crate::require_permission; +use crate::{ error::ApiError, extractors::{AuthContext, AuthState}, permissions::{Permission, UserRole}, }; -use crate::require_permission; use axum::{ Json, extract::{Path, Query, State}, diff --git a/src/api/routes/v1/mod.rs b/crates/codex-api/src/routes/v1/mod.rs similarity index 97% rename from src/api/routes/v1/mod.rs rename to crates/codex-api/src/routes/v1/mod.rs index 254efbca..2f5094dc 100644 --- a/src/api/routes/v1/mod.rs +++ b/crates/codex-api/src/routes/v1/mod.rs @@ -26,7 +26,7 @@ pub mod dto; pub mod handlers; mod routes; -use crate::api::extractors::AppState; +use crate::extractors::AppState; use axum::Router; use std::sync::Arc; diff --git a/src/api/routes/v1/routes/admin.rs b/crates/codex-api/src/routes/v1/routes/admin.rs similarity index 99% rename from src/api/routes/v1/routes/admin.rs rename to crates/codex-api/src/routes/v1/routes/admin.rs index fd584747..66159d35 100644 --- a/src/api/routes/v1/routes/admin.rs +++ b/crates/codex-api/src/routes/v1/routes/admin.rs @@ -3,7 +3,7 @@ //! Handles administrative operations including settings, sharing tags, and cleanup tasks. use super::super::handlers; -use crate::api::extractors::AppState; +use crate::extractors::AppState; use axum::{ Router, routing::{delete, get, patch, post, put}, diff --git a/src/api/routes/v1/routes/auth.rs b/crates/codex-api/src/routes/v1/routes/auth.rs similarity index 96% rename from src/api/routes/v1/routes/auth.rs rename to crates/codex-api/src/routes/v1/routes/auth.rs index 17a608c6..233d1f04 100644 --- a/src/api/routes/v1/routes/auth.rs +++ b/crates/codex-api/src/routes/v1/routes/auth.rs @@ -3,7 +3,7 @@ //! Handles user authentication including login, registration, logout, and email verification. use super::super::handlers; -use crate::api::extractors::AppState; +use crate::extractors::AppState; use axum::{ Router, routing::{get, post}, diff --git a/src/api/routes/v1/routes/books.rs b/crates/codex-api/src/routes/v1/routes/books.rs similarity index 99% rename from src/api/routes/v1/routes/books.rs rename to crates/codex-api/src/routes/v1/routes/books.rs index 148cbc61..5e551bf1 100644 --- a/src/api/routes/v1/routes/books.rs +++ b/crates/codex-api/src/routes/v1/routes/books.rs @@ -4,7 +4,7 @@ //! and file downloads. use super::super::handlers; -use crate::api::extractors::AppState; +use crate::extractors::AppState; use axum::{ Router, routing::{delete, get, patch, post, put}, diff --git a/src/api/routes/v1/routes/libraries.rs b/crates/codex-api/src/routes/v1/routes/libraries.rs similarity index 99% rename from src/api/routes/v1/routes/libraries.rs rename to crates/codex-api/src/routes/v1/routes/libraries.rs index fe0d0b81..a63dfc1e 100644 --- a/src/api/routes/v1/routes/libraries.rs +++ b/crates/codex-api/src/routes/v1/routes/libraries.rs @@ -4,7 +4,7 @@ //! book/series listings. use super::super::handlers; -use crate::api::extractors::AppState; +use crate::extractors::AppState; use axum::{ Router, routing::{delete, get, patch, post}, diff --git a/src/api/routes/v1/routes/misc.rs b/crates/codex-api/src/routes/v1/routes/misc.rs similarity index 99% rename from src/api/routes/v1/routes/misc.rs rename to crates/codex-api/src/routes/v1/routes/misc.rs index b7d62e76..1399b495 100644 --- a/src/api/routes/v1/routes/misc.rs +++ b/crates/codex-api/src/routes/v1/routes/misc.rs @@ -4,7 +4,7 @@ //! filesystem browsing, and real-time events. use super::super::handlers; -use crate::api::extractors::AppState; +use crate::extractors::AppState; use axum::{ Router, routing::{delete, get, post}, diff --git a/src/api/routes/v1/routes/mod.rs b/crates/codex-api/src/routes/v1/routes/mod.rs similarity index 97% rename from src/api/routes/v1/routes/mod.rs rename to crates/codex-api/src/routes/v1/routes/mod.rs index 41ef2929..fb29d7c6 100644 --- a/src/api/routes/v1/routes/mod.rs +++ b/crates/codex-api/src/routes/v1/routes/mod.rs @@ -20,7 +20,7 @@ mod user; mod user_plugins; mod users; -use crate::api::extractors::AppState; +use crate::extractors::AppState; use axum::Router; use std::sync::Arc; diff --git a/src/api/routes/v1/routes/observability.rs b/crates/codex-api/src/routes/v1/routes/observability.rs similarity index 97% rename from src/api/routes/v1/routes/observability.rs rename to crates/codex-api/src/routes/v1/routes/observability.rs index 53a1e02b..f1c67aa4 100644 --- a/src/api/routes/v1/routes/observability.rs +++ b/crates/codex-api/src/routes/v1/routes/observability.rs @@ -6,7 +6,7 @@ //! collector. use super::super::handlers; -use crate::api::extractors::AppState; +use crate::extractors::AppState; use axum::{ Router, extract::DefaultBodyLimit, diff --git a/src/api/routes/v1/routes/oidc.rs b/crates/codex-api/src/routes/v1/routes/oidc.rs similarity index 95% rename from src/api/routes/v1/routes/oidc.rs rename to crates/codex-api/src/routes/v1/routes/oidc.rs index d3ef1929..728b21a1 100644 --- a/src/api/routes/v1/routes/oidc.rs +++ b/crates/codex-api/src/routes/v1/routes/oidc.rs @@ -4,7 +4,7 @@ //! These routes enable authentication via external identity providers. use super::super::handlers::oidc; -use crate::api::extractors::AppState; +use crate::extractors::AppState; use axum::{ Router, routing::{get, post}, diff --git a/src/api/routes/v1/routes/plugins.rs b/crates/codex-api/src/routes/v1/routes/plugins.rs similarity index 95% rename from src/api/routes/v1/routes/plugins.rs rename to crates/codex-api/src/routes/v1/routes/plugins.rs index 5a1e0f37..dafe3eb3 100644 --- a/src/api/routes/v1/routes/plugins.rs +++ b/crates/codex-api/src/routes/v1/routes/plugins.rs @@ -5,7 +5,7 @@ //! - Plugin method execution use super::super::handlers; -use crate::api::extractors::AppState; +use crate::extractors::AppState; use axum::{ Router, routing::{get, post}, diff --git a/src/api/routes/v1/routes/recommendations.rs b/crates/codex-api/src/routes/v1/routes/recommendations.rs similarity index 96% rename from src/api/routes/v1/routes/recommendations.rs rename to crates/codex-api/src/routes/v1/routes/recommendations.rs index b4ec41ba..c9c30c07 100644 --- a/src/api/routes/v1/routes/recommendations.rs +++ b/crates/codex-api/src/routes/v1/routes/recommendations.rs @@ -3,7 +3,7 @@ //! Handles recommendation endpoints: get, refresh, and dismiss. use super::super::handlers; -use crate::api::extractors::AppState; +use crate::extractors::AppState; use axum::{ Router, routing::{get, post}, diff --git a/src/api/routes/v1/routes/releases.rs b/crates/codex-api/src/routes/v1/routes/releases.rs similarity index 98% rename from src/api/routes/v1/routes/releases.rs rename to crates/codex-api/src/routes/v1/routes/releases.rs index 7e7de9d2..a7af7143 100644 --- a/src/api/routes/v1/routes/releases.rs +++ b/crates/codex-api/src/routes/v1/routes/releases.rs @@ -5,7 +5,7 @@ //! inbox and the admin source-management endpoints. use super::super::handlers; -use crate::api::extractors::AppState; +use crate::extractors::AppState; use axum::{ Router, routing::{get, patch, post}, diff --git a/src/api/routes/v1/routes/series.rs b/crates/codex-api/src/routes/v1/routes/series.rs similarity index 99% rename from src/api/routes/v1/routes/series.rs rename to crates/codex-api/src/routes/v1/routes/series.rs index 87abe4c0..4dcab60a 100644 --- a/src/api/routes/v1/routes/series.rs +++ b/crates/codex-api/src/routes/v1/routes/series.rs @@ -4,7 +4,7 @@ //! covers, ratings, and more. use super::super::handlers; -use crate::api::extractors::AppState; +use crate::extractors::AppState; use axum::{ Router, routing::{delete, get, patch, post, put}, diff --git a/src/api/routes/v1/routes/setup.rs b/crates/codex-api/src/routes/v1/routes/setup.rs similarity index 95% rename from src/api/routes/v1/routes/setup.rs rename to crates/codex-api/src/routes/v1/routes/setup.rs index cb21a198..8e8f90b3 100644 --- a/src/api/routes/v1/routes/setup.rs +++ b/crates/codex-api/src/routes/v1/routes/setup.rs @@ -3,7 +3,7 @@ //! Handles initial application setup when no users exist. use super::super::handlers; -use crate::api::extractors::AppState; +use crate::extractors::AppState; use axum::{ Router, routing::{get, patch, post}, diff --git a/src/api/routes/v1/routes/tasks.rs b/crates/codex-api/src/routes/v1/routes/tasks.rs similarity index 98% rename from src/api/routes/v1/routes/tasks.rs rename to crates/codex-api/src/routes/v1/routes/tasks.rs index 7c1b1a60..5aefcd92 100644 --- a/src/api/routes/v1/routes/tasks.rs +++ b/crates/codex-api/src/routes/v1/routes/tasks.rs @@ -3,7 +3,7 @@ //! Handles task queue operations and thumbnail generation tasks. use super::super::handlers; -use crate::api::extractors::AppState; +use crate::extractors::AppState; use axum::{ Router, routing::{delete, get, post}, diff --git a/src/api/routes/v1/routes/user.rs b/crates/codex-api/src/routes/v1/routes/user.rs similarity index 98% rename from src/api/routes/v1/routes/user.rs rename to crates/codex-api/src/routes/v1/routes/user.rs index dc9c5eb0..e06d1cc7 100644 --- a/src/api/routes/v1/routes/user.rs +++ b/crates/codex-api/src/routes/v1/routes/user.rs @@ -3,7 +3,7 @@ //! Handles current user's preferences, ratings, and API keys. use super::super::handlers; -use crate::api::extractors::AppState; +use crate::extractors::AppState; use axum::{ Router, routing::{delete, get, patch, post, put}, diff --git a/src/api/routes/v1/routes/user_plugins.rs b/crates/codex-api/src/routes/v1/routes/user_plugins.rs similarity index 98% rename from src/api/routes/v1/routes/user_plugins.rs rename to crates/codex-api/src/routes/v1/routes/user_plugins.rs index af05680d..789beb55 100644 --- a/src/api/routes/v1/routes/user_plugins.rs +++ b/crates/codex-api/src/routes/v1/routes/user_plugins.rs @@ -3,7 +3,7 @@ //! Handles user plugin management: listing, enabling/disabling, OAuth flows. use super::super::handlers; -use crate::api::extractors::AppState; +use crate::extractors::AppState; use axum::{ Router, routing::{get, patch, post}, diff --git a/src/api/routes/v1/routes/users.rs b/crates/codex-api/src/routes/v1/routes/users.rs similarity index 97% rename from src/api/routes/v1/routes/users.rs rename to crates/codex-api/src/routes/v1/routes/users.rs index 2ad9013d..91aadf12 100644 --- a/src/api/routes/v1/routes/users.rs +++ b/crates/codex-api/src/routes/v1/routes/users.rs @@ -3,7 +3,7 @@ //! Handles user administration including CRUD operations and sharing tag assignments. use super::super::handlers; -use crate::api::extractors::AppState; +use crate::extractors::AppState; use axum::{ Router, routing::{delete, get, patch, post, put}, diff --git a/src/web.rs b/crates/codex-api/src/web.rs similarity index 100% rename from src/web.rs rename to crates/codex-api/src/web.rs diff --git a/docs/dev/contributing/development.md b/docs/dev/contributing/development.md index d094a884..4435c32b 100644 --- a/docs/dev/contributing/development.md +++ b/docs/dev/contributing/development.md @@ -305,20 +305,32 @@ cargo clippy ## Project Structure +Codex is a Cargo workspace. The top-level binary crate (`src/`) is intentionally thin; the bulk of the code lives in sibling crates under `crates/`. Editing one subsystem only recompiles that crate and its downstream consumers, which keeps warm rebuilds fast. + ``` codex/ -├── src/ -│ ├── api/ # HTTP API handlers -│ ├── commands/ # CLI commands -│ ├── config/ # Configuration management -│ ├── db/ # Database layer -│ ├── parsers/ # File format parsers -│ ├── scanner/ # File scanning logic -│ └── utils/ # Utility functions -├── migration/ # Database migrations -├── tests/ # Integration tests -└── docs/ # Documentation -``` +├── src/ # codex (binary) crate +│ ├── main.rs # CLI entry point +│ └── commands/ # Per-subcommand orchestration (scan, serve, ...) +├── crates/ +│ ├── codex-api/ # HTTP API: axum routes, OPDS, Komga, observability +│ ├── codex-config/ # YAML config + env overrides +│ ├── codex-db/ # SeaORM entities + repositories +│ ├── codex-events/ # In-process event broadcaster +│ ├── codex-models/ # Cross-layer DTOs and shared types +│ ├── codex-parsers/ # CBZ/CBR/EPUB/PDF parsing +│ ├── codex-scanner/ # Library scan workflow +│ ├── codex-scheduler/ # Cron/interval scheduler +│ ├── codex-search/ # In-memory fuzzy search index +│ ├── codex-services/ # Business logic (auth, plugins, metadata, ...) +│ ├── codex-tasks/ # Background worker + task handlers +│ └── codex-utils/ # Crypto, JWT, hashing, error types +├── migration/ # SeaORM migrations (own crate, used by codex-db) +├── tests/ # Integration tests against codex-api +└── docs/ # Documentation +``` + +Per-crate builds work in isolation, e.g. `cargo build -p codex-parsers`. ## Database Migrations diff --git a/src/commands/common.rs b/src/commands/common.rs index d911ad7e..215f5542 100644 --- a/src/commands/common.rs +++ b/src/commands/common.rs @@ -1,4 +1,4 @@ -use crate::observability::ObservabilityHandle; +use codex_api::observability::ObservabilityHandle; use codex_config::{Config, DatabaseConfig, DatabaseType, EnvOverride}; use codex_db::Database; use codex_events::EventBroadcaster; @@ -168,12 +168,12 @@ pub fn init_tracing(config: &Config) -> anyhow::Result<TracingHandles> { // Initialize OTel providers (no-op when disabled or feature off). Done // before constructing the bridge layer so the global tracer is in place // for any code that grabs it via `global::tracer(...)` later. - let observability = crate::observability::init(&config.observability)?; + let observability = codex_api::observability::init(&config.observability)?; let fmt_layer = fmt::layer() .with_writer(writer) .with_ansi(ansi_enabled) - .event_format(crate::observability::TraceContextFormat::default()); + .event_format(codex_api::observability::TraceContextFormat::default()); // Compose subscribers inline: a generic helper here trips up the // Layer<S>/Subscriber bounds because each `.with(...)` changes S, so the diff --git a/src/commands/openapi.rs b/src/commands/openapi.rs index 951a18ef..02eb754c 100644 --- a/src/commands/openapi.rs +++ b/src/commands/openapi.rs @@ -2,7 +2,7 @@ use anyhow::Result; use std::path::PathBuf; use utoipa::OpenApi; -use crate::api::docs::ApiDoc; +use codex_api::docs::ApiDoc; /// Export OpenAPI specification to a file /// diff --git a/src/commands/seed.rs b/src/commands/seed.rs index 9f58b6cc..5157ce45 100644 --- a/src/commands/seed.rs +++ b/src/commands/seed.rs @@ -1,8 +1,8 @@ -use crate::api::permissions::{ - ADMIN_PERMISSIONS, MAINTAINER_PERMISSIONS, READER_PERMISSIONS, serialize_permissions, -}; use anyhow::{Context, Result}; use chrono::Utc; +use codex_api::permissions::{ + ADMIN_PERMISSIONS, MAINTAINER_PERMISSIONS, READER_PERMISSIONS, serialize_permissions, +}; use codex_config::{Config, EnvOverride}; use codex_db::Database; use codex_db::entities::{api_keys, plugins::PluginPermission, users}; @@ -190,7 +190,7 @@ async fn seed_users( db_conn: &sea_orm::DatabaseConnection, seed_config: Option<&SeedConfig>, ) -> Result<()> { - use crate::api::permissions::UserRole; + use codex_api::permissions::UserRole; // Define users to create: (username, email, role, permissions for API key) let users_to_create = [ @@ -464,7 +464,7 @@ fn generate_random_password(length: usize) -> String { fn generate_api_key( user_id: Uuid, name: String, - permissions: &std::collections::HashSet<crate::api::permissions::Permission>, + permissions: &std::collections::HashSet<codex_api::permissions::Permission>, ) -> Result<(String, api_keys::Model)> { let mut rng = rand::rng(); @@ -522,7 +522,7 @@ mod tests { fn test_generate_api_key() { let user_id = Uuid::new_v4(); let mut permissions = std::collections::HashSet::new(); - permissions.insert(crate::api::permissions::Permission::LibrariesRead); + permissions.insert(codex_api::permissions::Permission::LibrariesRead); let (api_key, model) = generate_api_key(user_id, "Test Key".to_string(), &permissions).unwrap(); diff --git a/src/commands/serve.rs b/src/commands/serve.rs index d8625588..1993be30 100644 --- a/src/commands/serve.rs +++ b/src/commands/serve.rs @@ -140,7 +140,7 @@ pub async fn serve_command(config_path: PathBuf) -> anyhow::Result<()> { // Refresh the inventory metric snapshot every 30s so the OTel observable // gauges have current values. Cheap: five `COUNT(*)` queries. The poller // exits as soon as the cancellation token fires. - let inventory_poller_handle = crate::observability::inventory::spawn_poller( + let inventory_poller_handle = codex_api::observability::inventory::spawn_poller( Arc::new(db.sea_orm_connection().clone()), std::time::Duration::from_secs(30), background_task_cancel.clone(), @@ -459,7 +459,7 @@ pub async fn serve_command(config_path: PathBuf) -> anyhow::Result<()> { db.sea_orm_connection().clone(), config.auth.refresh_token_expiry_days, )); - let api_state = Arc::new(crate::api::AppState { + let api_state = Arc::new(codex_api::AppState { db: db.sea_orm_connection().clone(), jwt_service: Arc::new(codex_utils::jwt::JwtService::new( config.auth.jwt_secret.clone(), @@ -486,7 +486,7 @@ pub async fn serve_command(config_path: PathBuf) -> anyhow::Result<()> { pdf_page_cache, pdf_handle_cache, inflight_thumbnails: Arc::new(codex_services::InflightThumbnailTracker::new()), - user_auth_cache: Arc::new(crate::api::extractors::auth::UserAuthCache::new()), + user_auth_cache: Arc::new(codex_api::extractors::auth::UserAuthCache::new()), rate_limiter_service, plugin_manager: plugin_manager.clone(), plugin_metrics_service, @@ -496,6 +496,8 @@ pub async fn serve_command(config_path: PathBuf) -> anyhow::Result<()> { plugin_file_storage: Some(plugin_file_storage), scheduler_timezone: config.scheduler.timezone.clone(), fuzzy_index, + app_name: env!("CARGO_PKG_NAME"), + app_version: env!("CARGO_PKG_VERSION"), }); // Build router using API module @@ -511,7 +513,7 @@ pub async fn serve_command(config_path: PathBuf) -> anyhow::Result<()> { } info!(" Max page size: {}", config.api.max_page_size); - let app = crate::api::create_router(api_state, &config); + let app = codex_api::create_router(api_state, &config); info!("Registered routes:"); info!(" GET /health - Health check endpoint"); diff --git a/src/lib.rs b/src/lib.rs index 2a2db781..3c46160d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,11 +1,8 @@ -pub mod api; -pub mod observability; -pub mod web; - -// Re-exports of workspace-leaf crates so existing `codex::config::*`, -// `codex::db::*`, `codex::events::*`, `codex::models::*`, `codex::parsers::*`, -// `codex::services::*`, and `codex::utils::*` paths (used pervasively in -// integration tests) keep resolving without churn. +// Re-exports of workspace crates so existing `codex::<module>::*` paths used +// pervasively in integration tests keep resolving without churn. +pub use codex_api as api; +pub use codex_api::observability; +pub use codex_api::web; pub use codex_config as config; pub use codex_db as db; pub use codex_events as events; diff --git a/src/main.rs b/src/main.rs index d6ed6f6f..3e011479 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,7 +1,4 @@ -mod api; mod commands; -mod observability; -mod web; use clap::{Parser, Subcommand}; use commands::{ diff --git a/tests/api/oidc.rs b/tests/api/oidc.rs index 46187d03..aa0dbb6f 100644 --- a/tests/api/oidc.rs +++ b/tests/api/oidc.rs @@ -107,6 +107,8 @@ async fn create_test_state_with_oidc( plugin_file_storage: None, scheduler_timezone: "UTC".to_string(), fuzzy_index: Arc::new(codex::search::FuzzyIndex::empty()), + app_name: env!("CARGO_PKG_NAME"), + app_version: env!("CARGO_PKG_VERSION"), }) } diff --git a/tests/api/pdf_cache.rs b/tests/api/pdf_cache.rs index 3d021de3..d1ba70d2 100644 --- a/tests/api/pdf_cache.rs +++ b/tests/api/pdf_cache.rs @@ -106,6 +106,8 @@ async fn create_test_app_state_with_pdf_cache( plugin_file_storage: None, scheduler_timezone: "UTC".to_string(), fuzzy_index: Arc::new(codex::search::FuzzyIndex::empty()), + app_name: env!("CARGO_PKG_NAME"), + app_version: env!("CARGO_PKG_VERSION"), }) } diff --git a/tests/api/rate_limit.rs b/tests/api/rate_limit.rs index e5251a50..bf35166f 100644 --- a/tests/api/rate_limit.rs +++ b/tests/api/rate_limit.rs @@ -139,6 +139,8 @@ async fn create_rate_limited_app_state( plugin_file_storage: None, scheduler_timezone: "UTC".to_string(), fuzzy_index: Arc::new(codex::search::FuzzyIndex::empty()), + app_name: env!("CARGO_PKG_NAME"), + app_version: env!("CARGO_PKG_VERSION"), }) } diff --git a/tests/api/refresh_token.rs b/tests/api/refresh_token.rs index b4c5f747..cdad83ee 100644 --- a/tests/api/refresh_token.rs +++ b/tests/api/refresh_token.rs @@ -102,6 +102,8 @@ async fn build_state(db: DatabaseConnection, refresh_enabled: bool) -> Arc<AppSt plugin_file_storage: None, scheduler_timezone: "UTC".to_string(), fuzzy_index: Arc::new(codex::search::FuzzyIndex::empty()), + app_name: env!("CARGO_PKG_NAME"), + app_version: env!("CARGO_PKG_VERSION"), }) } diff --git a/tests/api/task_metrics.rs b/tests/api/task_metrics.rs index a05771f7..f24fe4c1 100644 --- a/tests/api/task_metrics.rs +++ b/tests/api/task_metrics.rs @@ -100,6 +100,8 @@ async fn create_test_app_state_with_metrics(db: DatabaseConnection) -> Arc<AppSt plugin_file_storage: None, scheduler_timezone: "UTC".to_string(), fuzzy_index: Arc::new(codex::search::FuzzyIndex::empty()), + app_name: env!("CARGO_PKG_NAME"), + app_version: env!("CARGO_PKG_VERSION"), }) } diff --git a/tests/common/http.rs b/tests/common/http.rs index 02162a9d..4343a72d 100644 --- a/tests/common/http.rs +++ b/tests/common/http.rs @@ -84,6 +84,8 @@ pub async fn create_test_auth_state(db: DatabaseConnection) -> Arc<AuthState> { plugin_file_storage: None, scheduler_timezone: "UTC".to_string(), fuzzy_index: Arc::new(codex::search::FuzzyIndex::empty()), + app_name: env!("CARGO_PKG_NAME"), + app_version: env!("CARGO_PKG_VERSION"), }) } @@ -149,6 +151,8 @@ pub async fn create_test_app_state(db: DatabaseConnection) -> Arc<AppState> { plugin_file_storage: None, scheduler_timezone: "UTC".to_string(), fuzzy_index: Arc::new(codex::search::FuzzyIndex::empty()), + app_name: env!("CARGO_PKG_NAME"), + app_version: env!("CARGO_PKG_VERSION"), }) } @@ -239,6 +243,8 @@ pub async fn create_test_router(state: Arc<AuthState>) -> Router { plugin_file_storage: None, scheduler_timezone: "UTC".to_string(), fuzzy_index: Arc::new(codex::search::FuzzyIndex::empty()), + app_name: env!("CARGO_PKG_NAME"), + app_version: env!("CARGO_PKG_VERSION"), }); let config = create_test_config(); create_router(app_state, &config) From 0fe7c72654694b8316545c7e1ae34649425f25a2 Mon Sep 17 00:00:00 2001 From: Sylvain Cau <ashdevfr@gmail.com> Date: Sat, 23 May 2026 21:49:37 -0700 Subject: [PATCH 11/14] chore(workspace): unify crate versions and fix Docker builds Two follow-ups to the recent workspace extraction: - Add [workspace.package] to the root Cargo.toml as the single source of truth for version/edition and switch all 13 members to workspace inheritance. Cleans up the 0.0.0 vs 1.29.0 mismatch in cargo build output without touching release-prepare (its sed still matches the one ^version = "..." line, now under [workspace.package]). - Update Dockerfile and Dockerfile.dev so every workspace member's Cargo.toml is copied into the build context and stub src/lib.rs files are created per crate, restoring the dependency-cache layer for both the chef planner and the dev image. --- Cargo.lock | 26 +++++++++++++------------- Cargo.toml | 10 ++++++++-- Dockerfile | 2 ++ Dockerfile.dev | 23 +++++++++++++++++++++-- crates/codex-api/Cargo.toml | 4 ++-- crates/codex-config/Cargo.toml | 4 ++-- crates/codex-db/Cargo.toml | 4 ++-- crates/codex-events/Cargo.toml | 4 ++-- crates/codex-models/Cargo.toml | 4 ++-- crates/codex-parsers/Cargo.toml | 4 ++-- crates/codex-scanner/Cargo.toml | 4 ++-- crates/codex-scheduler/Cargo.toml | 4 ++-- crates/codex-search/Cargo.toml | 4 ++-- crates/codex-services/Cargo.toml | 4 ++-- crates/codex-tasks/Cargo.toml | 4 ++-- crates/codex-utils/Cargo.toml | 4 ++-- migration/Cargo.toml | 4 ++-- 17 files changed, 70 insertions(+), 43 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6c18b034..f80c6385 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -859,7 +859,7 @@ dependencies = [ [[package]] name = "codex-api" -version = "0.0.0" +version = "1.29.0" dependencies = [ "anyhow", "async-stream", @@ -919,7 +919,7 @@ dependencies = [ [[package]] name = "codex-config" -version = "0.0.0" +version = "1.29.0" dependencies = [ "anyhow", "serde", @@ -930,7 +930,7 @@ dependencies = [ [[package]] name = "codex-db" -version = "0.0.0" +version = "1.29.0" dependencies = [ "anyhow", "chrono", @@ -956,7 +956,7 @@ dependencies = [ [[package]] name = "codex-events" -version = "0.0.0" +version = "1.29.0" dependencies = [ "chrono", "serde", @@ -969,7 +969,7 @@ dependencies = [ [[package]] name = "codex-models" -version = "0.0.0" +version = "1.29.0" dependencies = [ "chrono", "lazy_static", @@ -981,7 +981,7 @@ dependencies = [ [[package]] name = "codex-parsers" -version = "0.0.0" +version = "1.29.0" dependencies = [ "anyhow", "chrono", @@ -1005,7 +1005,7 @@ dependencies = [ [[package]] name = "codex-scanner" -version = "0.0.0" +version = "1.29.0" dependencies = [ "anyhow", "chrono", @@ -1033,7 +1033,7 @@ dependencies = [ [[package]] name = "codex-scheduler" -version = "0.0.0" +version = "1.29.0" dependencies = [ "anyhow", "chrono", @@ -1056,7 +1056,7 @@ dependencies = [ [[package]] name = "codex-search" -version = "0.0.0" +version = "1.29.0" dependencies = [ "anyhow", "chrono", @@ -1076,7 +1076,7 @@ dependencies = [ [[package]] name = "codex-services" -version = "0.0.0" +version = "1.29.0" dependencies = [ "anyhow", "base64 0.22.1", @@ -1126,7 +1126,7 @@ dependencies = [ [[package]] name = "codex-tasks" -version = "0.0.0" +version = "1.29.0" dependencies = [ "anyhow", "chrono", @@ -1152,7 +1152,7 @@ dependencies = [ [[package]] name = "codex-utils" -version = "0.0.0" +version = "1.29.0" dependencies = [ "aes-gcm", "anyhow", @@ -3357,7 +3357,7 @@ dependencies = [ [[package]] name = "migration" -version = "0.1.0" +version = "1.29.0" dependencies = [ "chrono", "lazy_static", diff --git a/Cargo.toml b/Cargo.toml index 7ecf0e13..34dcd4a7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "codex" -version = "1.29.0" -edition = "2024" +version.workspace = true +edition.workspace = true description = "A next-generation digital library server for comics, manga, and ebooks" license = "AGPL-3.0-or-later" repository = "https://github.com/AshDevFr/codex" @@ -31,6 +31,12 @@ embed-frontend = ["codex-api/embed-frontend"] # so the dep is enabled here too. observability = ["codex-api/observability", "dep:tracing-opentelemetry"] +[workspace.package] +# Single source of truth for crate versions. `release-prepare` rewrites this +# line; every workspace member inherits via `version.workspace = true`. +version = "1.29.0" +edition = "2024" + [workspace] members = [ ".", diff --git a/Dockerfile b/Dockerfile index f3c8365a..1cf2fd42 100644 --- a/Dockerfile +++ b/Dockerfile @@ -39,6 +39,7 @@ FROM chef AS planner COPY Cargo.toml Cargo.lock ./ COPY assets/ ./assets/ COPY migration/ ./migration/ +COPY crates/ ./crates/ COPY src/ ./src/ RUN cargo chef prepare --recipe-path recipe.json @@ -57,6 +58,7 @@ RUN --mount=type=cache,target=/usr/local/cargo/registry \ COPY Cargo.toml Cargo.lock ./ COPY assets/ ./assets/ COPY migration/ ./migration/ +COPY crates/ ./crates/ COPY src/ ./src/ # Copy frontend dist from frontend-builder diff --git a/Dockerfile.dev b/Dockerfile.dev index 1936f8de..4a0123ce 100644 --- a/Dockerfile.dev +++ b/Dockerfile.dev @@ -48,10 +48,24 @@ RUN cargo install cargo-watch cargo-nextest --locked WORKDIR /app -# Copy Cargo workspace files for dependency caching +# Copy Cargo workspace manifests for dependency caching. Every workspace +# member's Cargo.toml must be present so the root manifest resolves; source +# is stubbed below and replaced wholesale by `COPY . .` further down. COPY Cargo.toml Cargo.lock ./ COPY .cargo/ ./.cargo/ COPY migration/Cargo.toml ./migration/ +COPY crates/codex-api/Cargo.toml ./crates/codex-api/ +COPY crates/codex-config/Cargo.toml ./crates/codex-config/ +COPY crates/codex-db/Cargo.toml ./crates/codex-db/ +COPY crates/codex-events/Cargo.toml ./crates/codex-events/ +COPY crates/codex-models/Cargo.toml ./crates/codex-models/ +COPY crates/codex-parsers/Cargo.toml ./crates/codex-parsers/ +COPY crates/codex-scanner/Cargo.toml ./crates/codex-scanner/ +COPY crates/codex-scheduler/Cargo.toml ./crates/codex-scheduler/ +COPY crates/codex-search/Cargo.toml ./crates/codex-search/ +COPY crates/codex-services/Cargo.toml ./crates/codex-services/ +COPY crates/codex-tasks/Cargo.toml ./crates/codex-tasks/ +COPY crates/codex-utils/Cargo.toml ./crates/codex-utils/ # Create dummy source files to build dependencies # Disable static linking to enable dlopen() for PDFium dynamic loading @@ -61,8 +75,13 @@ ENV RUSTFLAGS="-C target-feature=-crt-static" RUN mkdir -p src migration/src && \ echo "fn main() {}" > src/main.rs && \ echo "pub use sea_orm_migration::prelude::*; pub struct Migrator; impl MigratorTrait for Migrator { fn migrations() -> Vec<Box<dyn MigrationTrait>> { vec![] } }" > migration/src/lib.rs && \ + for crate in codex-api codex-config codex-db codex-events codex-models codex-parsers codex-scanner codex-scheduler codex-search codex-services codex-tasks codex-utils; do \ + mkdir -p "crates/$crate/src" && : > "crates/$crate/src/lib.rs"; \ + done && \ cargo build && \ - rm -rf src migration/src target/debug/.fingerprint/codex-* target/debug/.fingerprint/migration-* + rm -rf src migration/src crates/*/src \ + target/debug/.fingerprint/codex-* \ + target/debug/.fingerprint/migration-* # Copy the rest of the application COPY . . diff --git a/crates/codex-api/Cargo.toml b/crates/codex-api/Cargo.toml index 49ce935d..9e75f305 100644 --- a/crates/codex-api/Cargo.toml +++ b/crates/codex-api/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "codex-api" -version = "0.0.0" -edition = "2024" +version.workspace = true +edition.workspace = true publish = false [lib] diff --git a/crates/codex-config/Cargo.toml b/crates/codex-config/Cargo.toml index 8b75a138..0abc81d2 100644 --- a/crates/codex-config/Cargo.toml +++ b/crates/codex-config/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "codex-config" -version = "0.0.0" -edition = "2024" +version.workspace = true +edition.workspace = true publish = false [lib] diff --git a/crates/codex-db/Cargo.toml b/crates/codex-db/Cargo.toml index a185d4e8..956bf443 100644 --- a/crates/codex-db/Cargo.toml +++ b/crates/codex-db/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "codex-db" -version = "0.0.0" -edition = "2024" +version.workspace = true +edition.workspace = true publish = false [lib] diff --git a/crates/codex-events/Cargo.toml b/crates/codex-events/Cargo.toml index 04716083..68ef981a 100644 --- a/crates/codex-events/Cargo.toml +++ b/crates/codex-events/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "codex-events" -version = "0.0.0" -edition = "2024" +version.workspace = true +edition.workspace = true publish = false [lib] diff --git a/crates/codex-models/Cargo.toml b/crates/codex-models/Cargo.toml index 786471ee..15c68ecd 100644 --- a/crates/codex-models/Cargo.toml +++ b/crates/codex-models/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "codex-models" -version = "0.0.0" -edition = "2024" +version.workspace = true +edition.workspace = true publish = false [lib] diff --git a/crates/codex-parsers/Cargo.toml b/crates/codex-parsers/Cargo.toml index 4ce59322..20cf96d1 100644 --- a/crates/codex-parsers/Cargo.toml +++ b/crates/codex-parsers/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "codex-parsers" -version = "0.0.0" -edition = "2024" +version.workspace = true +edition.workspace = true publish = false [lib] diff --git a/crates/codex-scanner/Cargo.toml b/crates/codex-scanner/Cargo.toml index 65077e57..907ec93a 100644 --- a/crates/codex-scanner/Cargo.toml +++ b/crates/codex-scanner/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "codex-scanner" -version = "0.0.0" -edition = "2024" +version.workspace = true +edition.workspace = true publish = false [lib] diff --git a/crates/codex-scheduler/Cargo.toml b/crates/codex-scheduler/Cargo.toml index a848a73d..bfe0b49c 100644 --- a/crates/codex-scheduler/Cargo.toml +++ b/crates/codex-scheduler/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "codex-scheduler" -version = "0.0.0" -edition = "2024" +version.workspace = true +edition.workspace = true publish = false [lib] diff --git a/crates/codex-search/Cargo.toml b/crates/codex-search/Cargo.toml index 5da12cec..eb3142c9 100644 --- a/crates/codex-search/Cargo.toml +++ b/crates/codex-search/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "codex-search" -version = "0.0.0" -edition = "2024" +version.workspace = true +edition.workspace = true publish = false [lib] diff --git a/crates/codex-services/Cargo.toml b/crates/codex-services/Cargo.toml index 38b15061..83ad0758 100644 --- a/crates/codex-services/Cargo.toml +++ b/crates/codex-services/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "codex-services" -version = "0.0.0" -edition = "2024" +version.workspace = true +edition.workspace = true publish = false [lib] diff --git a/crates/codex-tasks/Cargo.toml b/crates/codex-tasks/Cargo.toml index f2178f5c..ce2594fd 100644 --- a/crates/codex-tasks/Cargo.toml +++ b/crates/codex-tasks/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "codex-tasks" -version = "0.0.0" -edition = "2024" +version.workspace = true +edition.workspace = true publish = false [lib] diff --git a/crates/codex-utils/Cargo.toml b/crates/codex-utils/Cargo.toml index 9dc122c3..1866640b 100644 --- a/crates/codex-utils/Cargo.toml +++ b/crates/codex-utils/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "codex-utils" -version = "0.0.0" -edition = "2024" +version.workspace = true +edition.workspace = true publish = false [lib] diff --git a/migration/Cargo.toml b/migration/Cargo.toml index e4ec3a46..1c71d0dc 100644 --- a/migration/Cargo.toml +++ b/migration/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "migration" -version = "0.1.0" -edition = "2024" +version.workspace = true +edition.workspace = true publish = false [lib] From 8042095f8be34806ccfb2863b81034ba7438ecda Mon Sep 17 00:00:00 2001 From: Sylvain Cau <ashdevfr@gmail.com> Date: Sat, 23 May 2026 23:02:03 -0700 Subject: [PATCH 12/14] fix(api): point rust-embed at workspace-root web/dist The codex-api extraction moved web.rs from the root crate into crates/codex-api/, so `#[folder = "web/dist"]` started resolving against this crate's CARGO_MANIFEST_DIR (crates/codex-api/web/dist) instead of the workspace root. Docker builds with embed-frontend failed at the RustEmbed derive, cascading into a wall of "StaticAssets::get not found" errors in CI. Default-feature local builds passed because embed-frontend was off. Emit the absolute dist path from build.rs as CODEX_WEB_DIST, alongside the existing CODEX_BIN_VERSION (both fragile walk-up-to-workspace-root assumptions now live in one file), consume it via `$CODEX_WEB_DIST` in web.rs, and enable rust-embed's interpolate-folder-path feature so the variable expands. --- crates/codex-api/Cargo.toml | 7 +++++-- crates/codex-api/build.rs | 31 +++++++++++++++++++++---------- crates/codex-api/src/web.rs | 6 ++++-- 3 files changed, 30 insertions(+), 14 deletions(-) diff --git a/crates/codex-api/Cargo.toml b/crates/codex-api/Cargo.toml index 9e75f305..7d268619 100644 --- a/crates/codex-api/Cargo.toml +++ b/crates/codex-api/Cargo.toml @@ -99,8 +99,11 @@ rand = "0.10" # Archive utilities for export handlers zip = "8.1" -# Static file embedding for frontend (gated by `embed-frontend`) -rust-embed = "8.5" +# Static file embedding for frontend (gated by `embed-frontend`). +# `interpolate-folder-path` lets `#[folder = "$CODEX_WEB_DIST"]` expand the env +# var emitted by build.rs (the dist tree lives at the workspace root, not under +# this crate's CARGO_MANIFEST_DIR). +rust-embed = { version = "8.5", features = ["interpolate-folder-path"] } mime_guess = "2.0" # Logging (for sqlx log filter adjustments inside init paths) diff --git a/crates/codex-api/build.rs b/crates/codex-api/build.rs index fa14f2de..320789e1 100644 --- a/crates/codex-api/build.rs +++ b/crates/codex-api/build.rs @@ -1,22 +1,29 @@ -//! Surface the codex binary's version to the API documentation generator. +//! Surface workspace-root paths/values to the API crate at compile time. //! -//! The OpenAPI spec embeds a `version` string at compile time via the -//! `utoipa::OpenApi` derive. Inside the `codex-api` crate, `env!("CARGO_PKG_VERSION")` -//! resolves to this crate's own `0.0.0` workspace-internal placeholder, which -//! is not the user-visible version. Read the root `Cargo.toml` once here and -//! re-emit it as a build-time env var the derive can pick up. +//! Two things get hoisted out of this build script: +//! +//! - `CODEX_BIN_VERSION`: the OpenAPI spec embeds a `version` string at compile +//! time via the `utoipa::OpenApi` derive. Inside the `codex-api` crate, +//! `env!("CARGO_PKG_VERSION")` resolves to this crate's own placeholder, +//! which is not the user-visible version. Read the root `Cargo.toml` once +//! here and re-emit it as a build-time env var the derive can pick up. +//! - `CODEX_WEB_DIST`: rust-embed's `#[folder = ...]` resolves relative to the +//! consuming crate's `CARGO_MANIFEST_DIR`. The frontend's `web/dist` lives at +//! the workspace root, not under `crates/codex-api/`, so emit the absolute +//! path here for `src/web.rs` to consume. use std::path::PathBuf; fn main() { - // Root manifest is two levels up from this crate's manifest dir. + // Workspace root is two levels up from this crate's manifest dir. let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); - let root_manifest = manifest_dir + let workspace_root = manifest_dir .parent() .and_then(|p| p.parent()) - .map(|p| p.join("Cargo.toml")) - .expect("codex-api should live under <workspace>/crates/codex-api"); + .expect("codex-api should live under <workspace>/crates/codex-api") + .to_path_buf(); + let root_manifest = workspace_root.join("Cargo.toml"); println!("cargo:rerun-if-changed={}", root_manifest.display()); let contents = std::fs::read_to_string(&root_manifest) @@ -31,4 +38,8 @@ fn main() { .expect("root Cargo.toml must have a `version = \"...\"` line in [package]"); println!("cargo:rustc-env=CODEX_BIN_VERSION={version}"); + println!( + "cargo:rustc-env=CODEX_WEB_DIST={}", + workspace_root.join("web").join("dist").display() + ); } diff --git a/crates/codex-api/src/web.rs b/crates/codex-api/src/web.rs index 2af92b73..bf523228 100644 --- a/crates/codex-api/src/web.rs +++ b/crates/codex-api/src/web.rs @@ -16,10 +16,12 @@ use axum::{ #[cfg(feature = "embed-frontend")] use rust_embed::RustEmbed; -// Embed the frontend dist directory when the feature is enabled +// Embed the frontend dist directory when the feature is enabled. +// `CODEX_WEB_DIST` is set by build.rs to the absolute path of <workspace>/web/dist, +// since rust-embed resolves relative folders against this crate's manifest dir. #[cfg(feature = "embed-frontend")] #[derive(RustEmbed)] -#[folder = "web/dist"] +#[folder = "$CODEX_WEB_DIST"] struct StaticAssets; /// Serves static files from the embedded frontend (production mode) From faf6b59bdd30dfd435850aa49e900d15bde4053d Mon Sep 17 00:00:00 2001 From: Sylvain Cau <ashdevfr@gmail.com> Date: Sat, 23 May 2026 23:19:41 -0700 Subject: [PATCH 13/14] refactor(workspace): extract codex-cli-common, scope ParserError to codex-parsers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two follow-up cleanups to the workspace split. codex-cli-common (new workspace crate): - Move the 923-line src/commands/common.rs (filesystem helpers, config loading, tracing init, database init, worker spawn/shutdown) into its own sibling crate. Root binary now contains only main.rs, subcommand orchestrators, and minimal glue. - Root [dependencies] drops tracing-subscriber, tracing-appender, and tracing-opentelemetry; they only powered init_tracing, which has moved. - Root observability feature now cascades to codex-cli-common/observability instead of pulling tracing-opentelemetry directly. - Subcommands updated to use codex_cli_common::* in place of crate::commands::common::*. ParserError (renamed from CodexError, moved out of codex-utils): - codex-utils no longer owns format-specific error variants (zip, image, quick-xml). The error type lives in codex-parsers::error as ParserError, scoped to its only real consumer. - Drop five dead variants from the old enum: Image, Xml, and Json were #[from] targets that no parser ever propagated via ?; FileNotFound and InvalidMetadata were never constructed. - codex-utils Cargo.toml drops image, quick-xml, zip, and thiserror direct deps; codex-parsers picks up thiserror for the new derive. - codex-scanner/analyzer.rs follows the move (only used UnsupportedFormat). Also: fix seven pre-existing broken doctest imports left over from the Phase 3 parsers extraction (use codex::parsers::* → use codex_parsers::*). nextest skips doctests by default, which masked them. Tests, clippy, fmt, cargo dist plan all clean. --- Cargo.lock | 39 ++++++++++++++++--- Cargo.toml | 15 +++---- crates/codex-cli-common/Cargo.toml | 39 +++++++++++++++++++ .../codex-cli-common/src/lib.rs | 0 crates/codex-parsers/Cargo.toml | 3 ++ crates/codex-parsers/src/cbr/parser.rs | 17 ++++---- crates/codex-parsers/src/cbz/parser.rs | 3 +- crates/codex-parsers/src/epub/parser.rs | 15 +++---- crates/codex-parsers/src/error.rs | 20 ++++++++++ crates/codex-parsers/src/isbn_utils.rs | 10 ++--- crates/codex-parsers/src/lib.rs | 6 ++- crates/codex-parsers/src/opf.rs | 4 +- crates/codex-parsers/src/pdf/parser.rs | 5 ++- crates/codex-parsers/src/series_json.rs | 6 +-- crates/codex-parsers/src/traits.rs | 2 +- crates/codex-scanner/src/analyzer.rs | 8 ++-- crates/codex-utils/Cargo.toml | 4 -- crates/codex-utils/src/deadline.rs | 2 +- crates/codex-utils/src/error.rs | 39 ------------------- crates/codex-utils/src/lib.rs | 8 ++-- crates/codex-utils/src/natural_sort.rs | 2 +- src/commands/migrate.rs | 2 +- src/commands/mod.rs | 1 - src/commands/serve.rs | 2 +- src/commands/tasks.rs | 2 +- src/commands/wait_for_migrations.rs | 4 +- src/commands/worker.rs | 2 +- 27 files changed, 153 insertions(+), 107 deletions(-) create mode 100644 crates/codex-cli-common/Cargo.toml rename src/commands/common.rs => crates/codex-cli-common/src/lib.rs (100%) create mode 100644 crates/codex-parsers/src/error.rs delete mode 100644 crates/codex-utils/src/error.rs diff --git a/Cargo.lock b/Cargo.lock index f80c6385..87745913 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -817,6 +817,7 @@ dependencies = [ "chrono", "clap", "codex-api", + "codex-cli-common", "codex-config", "codex-db", "codex-events", @@ -847,7 +848,6 @@ dependencies = [ "tokio-util", "tower", "tracing", - "tracing-appender", "tracing-opentelemetry 0.33.0", "tracing-subscriber", "tracing-test", @@ -917,6 +917,28 @@ dependencies = [ "zip", ] +[[package]] +name = "codex-cli-common" +version = "1.29.0" +dependencies = [ + "anyhow", + "codex-api", + "codex-config", + "codex-db", + "codex-events", + "codex-services", + "codex-tasks", + "sea-orm", + "serial_test", + "tempfile", + "tokio", + "tokio-util", + "tracing", + "tracing-appender", + "tracing-opentelemetry 0.33.0", + "tracing-subscriber", +] + [[package]] name = "codex-config" version = "1.29.0" @@ -997,6 +1019,7 @@ dependencies = [ "serde", "serde_json", "tempfile", + "thiserror 2.0.18", "tracing", "unrar", "urlencoding", @@ -1162,21 +1185,17 @@ dependencies = [ "chrono-tz", "codex-models", "cron", - "image", "jsonwebtoken", "md-5", - "quick-xml", "rand 0.10.0", "serde", "serde_json", "serial_test", "sha2", "tempfile", - "thiserror 2.0.18", "tokio", "unicode-normalization", "uuid", - "zip", ] [[package]] @@ -4967,6 +4986,7 @@ dependencies = [ "proc-macro2", "quote", "rust-embed-utils", + "shellexpand", "syn 2.0.117", "walkdir", ] @@ -5607,6 +5627,15 @@ dependencies = [ "lazy_static", ] +[[package]] +name = "shellexpand" +version = "3.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b1fdf65dd6331831494dd616b30351c38e96e45921a27745cf98490458b90bb" +dependencies = [ + "dirs", +] + [[package]] name = "shlex" version = "1.3.0" diff --git a/Cargo.toml b/Cargo.toml index 34dcd4a7..ac90a4bd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,7 +29,7 @@ embed-frontend = ["codex-api/embed-frontend"] # Enables the OpenTelemetry HTTP/runtime instrumentation in codex-api. # Root composes the OTel `tracing-opentelemetry` bridge layer in init_tracing, # so the dep is enabled here too. -observability = ["codex-api/observability", "dep:tracing-opentelemetry"] +observability = ["codex-api/observability", "codex-cli-common/observability"] [workspace.package] # Single source of truth for crate versions. `release-prepare` rewrites this @@ -53,6 +53,7 @@ members = [ "crates/codex-tasks", "crates/codex-scheduler", "crates/codex-api", + "crates/codex-cli-common", ] # Shared dependencies inherited by workspace members. Only deps that are @@ -76,6 +77,7 @@ uuid = { version = "1.0", features = ["v4", "serde"] } # Workspace-internal crates. Declaring them here keeps cross-crate path edges # in one place so members reference each other via `{ workspace = true }`. codex-api = { path = "crates/codex-api", default-features = false } +codex-cli-common = { path = "crates/codex-cli-common", default-features = false } codex-config = { path = "crates/codex-config" } codex-db = { path = "crates/codex-db" } codex-events = { path = "crates/codex-events" } @@ -108,6 +110,7 @@ uuid = { workspace = true } # Workspace-internal codex-api = { workspace = true } +codex-cli-common = { workspace = true } codex-config = { workspace = true } codex-db = { workspace = true } codex-events = { workspace = true } @@ -140,10 +143,6 @@ rand = "0.10" # Async helpers (commands/serve.rs uses tokio_util::sync::CancellationToken) tokio-util = { version = "0.7", features = ["io"] } -# Tracing subscribers / appenders (commands/common.rs init_tracing). -tracing-subscriber = { version = "0.3", features = ["env-filter"] } -tracing-appender = "0.2" - # Tabular output (commands/tasks.rs admin views) tabled = "0.20" @@ -153,11 +152,6 @@ axum = { version = "0.8", features = ["multipart"] } # Recursive file walking (commands/scan.rs) walkdir = "2.5" -# OpenTelemetry bridge layer composition in init_tracing. Optional so a -# `--no-default-features` build drops the dep entirely; enabled together with -# the root-level `observability` feature. -tracing-opentelemetry = { version = "0.33", optional = true } - [dev-dependencies] tempfile = { workspace = true } tower = { version = "0.5", features = ["util"] } @@ -191,6 +185,7 @@ base64 = "0.22" # context flows into request handlers. opentelemetry = "0.32" tracing-opentelemetry = "0.33" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } # ============================================================================= # Development Profile - Optimized for fast incremental builds diff --git a/crates/codex-cli-common/Cargo.toml b/crates/codex-cli-common/Cargo.toml new file mode 100644 index 00000000..b708ef1b --- /dev/null +++ b/crates/codex-cli-common/Cargo.toml @@ -0,0 +1,39 @@ +[package] +name = "codex-cli-common" +version.workspace = true +edition.workspace = true +publish = false + +[lib] +name = "codex_cli_common" +path = "src/lib.rs" + +[features] +default = [] +observability = ["codex-api/observability", "dep:tracing-opentelemetry"] + +[dependencies] +# Workspace-inherited +anyhow = { workspace = true } +tokio = { workspace = true } +tracing = { workspace = true } + +# Workspace-internal +codex-api = { workspace = true } +codex-config = { workspace = true } +codex-db = { workspace = true } +codex-events = { workspace = true } +codex-services = { workspace = true } +codex-tasks = { workspace = true } + +# Crate-specific +sea-orm = { version = "1.1", default-features = false } +tokio-util = { version = "0.7", features = ["io"] } +tracing-appender = "0.2" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +tracing-opentelemetry = { version = "0.33", optional = true } + +[dev-dependencies] +tempfile = { workspace = true } +serial_test = { workspace = true } +codex-db = { workspace = true, features = ["test-utils"] } diff --git a/src/commands/common.rs b/crates/codex-cli-common/src/lib.rs similarity index 100% rename from src/commands/common.rs rename to crates/codex-cli-common/src/lib.rs diff --git a/crates/codex-parsers/Cargo.toml b/crates/codex-parsers/Cargo.toml index 20cf96d1..2c0745a6 100644 --- a/crates/codex-parsers/Cargo.toml +++ b/crates/codex-parsers/Cargo.toml @@ -19,6 +19,9 @@ serde = { workspace = true } tracing = { workspace = true } codex-utils = { workspace = true } +# Error handling for ParserError +thiserror = "2.0" + # URL decoding (EPUB OPF hrefs) urlencoding = "2.1" diff --git a/crates/codex-parsers/src/cbr/parser.rs b/crates/codex-parsers/src/cbr/parser.rs index 5b4b564e..ffc2a43c 100644 --- a/crates/codex-parsers/src/cbr/parser.rs +++ b/crates/codex-parsers/src/cbr/parser.rs @@ -1,8 +1,9 @@ +use crate::error::{ParserError, Result}; use crate::image_utils::{create_page_info, is_image_file, process_image_data}; use crate::traits::FormatParser; use crate::{BookMetadata, FileFormat, parse_comic_info}; use chrono::{DateTime, Utc}; -use codex_utils::{CodexError, Result, hash_file}; +use codex_utils::hash_file; use std::path::Path; use unrar::Archive; @@ -37,10 +38,10 @@ impl FormatParser for CbrParser { // Open RAR archive for processing - we'll do everything in one pass let mut archive = Archive::new( path.to_str() - .ok_or_else(|| CodexError::ParseError("Invalid path encoding".to_string()))?, + .ok_or_else(|| ParserError::ParseError("Invalid path encoding".to_string()))?, ) .open_for_processing() - .map_err(|e| CodexError::ParseError(format!("Failed to open RAR archive: {}", e)))?; + .map_err(|e| ParserError::ParseError(format!("Failed to open RAR archive: {}", e)))?; // Collect all entries with their data let mut image_data_entries: Vec<(String, Vec<u8>, u64)> = Vec::new(); @@ -51,7 +52,7 @@ impl FormatParser for CbrParser { Ok(Some(h)) => h, Ok(None) => break, Err(e) => { - return Err(CodexError::ParseError(format!( + return Err(ParserError::ParseError(format!( "Failed to read RAR header: {}", e ))); @@ -64,7 +65,7 @@ impl FormatParser for CbrParser { // Skip directories if header.entry().is_directory() { archive = header.skip().map_err(|e| { - CodexError::ParseError(format!("Failed to skip directory: {}", e)) + ParserError::ParseError(format!("Failed to skip directory: {}", e)) })?; continue; } @@ -72,7 +73,7 @@ impl FormatParser for CbrParser { // Check for ComicInfo.xml if filename == "ComicInfo.xml" { let (xml_content, next) = header.read().map_err(|e| { - CodexError::ParseError(format!("Failed to read ComicInfo.xml: {}", e)) + ParserError::ParseError(format!("Failed to read ComicInfo.xml: {}", e)) })?; let xml_str = String::from_utf8_lossy(&xml_content).to_string(); @@ -84,7 +85,7 @@ impl FormatParser for CbrParser { // Read image data let (data, next) = header .read() - .map_err(|e| CodexError::ParseError(format!("Failed to read image: {}", e)))?; + .map_err(|e| ParserError::ParseError(format!("Failed to read image: {}", e)))?; image_data_entries.push((filename, data, unpacked_size)); archive = next; @@ -92,7 +93,7 @@ impl FormatParser for CbrParser { // Skip non-image, non-ComicInfo files archive = header .skip() - .map_err(|e| CodexError::ParseError(format!("Failed to skip file: {}", e)))?; + .map_err(|e| ParserError::ParseError(format!("Failed to skip file: {}", e)))?; } } diff --git a/crates/codex-parsers/src/cbz/parser.rs b/crates/codex-parsers/src/cbz/parser.rs index 6810953f..0a97dbda 100644 --- a/crates/codex-parsers/src/cbz/parser.rs +++ b/crates/codex-parsers/src/cbz/parser.rs @@ -1,8 +1,9 @@ +use crate::error::Result; use crate::image_utils::{create_page_info, is_image_file, process_image_data}; use crate::traits::FormatParser; use crate::{BookMetadata, FileFormat, parse_comic_info}; use chrono::{DateTime, Utc}; -use codex_utils::{Result, hash_file}; +use codex_utils::hash_file; use std::fs::File; use std::io::Read; use std::path::Path; diff --git a/crates/codex-parsers/src/epub/parser.rs b/crates/codex-parsers/src/epub/parser.rs index c5b00035..5dff5e30 100644 --- a/crates/codex-parsers/src/epub/parser.rs +++ b/crates/codex-parsers/src/epub/parser.rs @@ -1,3 +1,4 @@ +use crate::error::{ParserError, Result}; use crate::image_utils::{get_image_format, get_svg_dimensions, is_image_file}; use crate::isbn_utils::extract_isbns; use crate::metadata::{SpineItem, compute_epub_positions}; @@ -5,7 +6,7 @@ use crate::opf; use crate::traits::FormatParser; use crate::{BookMetadata, FileFormat, ImageFormat, PageInfo}; use chrono::{DateTime, Utc}; -use codex_utils::{CodexError, Result, hash_file}; +use codex_utils::hash_file; use image::GenericImageView; use std::collections::HashMap; use std::fs::File; @@ -142,7 +143,7 @@ impl EpubParser { pub fn find_root_file(archive: &mut ZipArchive<File>) -> Result<String> { let mut container_file = archive .by_name("META-INF/container.xml") - .map_err(|_| CodexError::ParseError("META-INF/container.xml not found".to_string()))?; + .map_err(|_| ParserError::ParseError("META-INF/container.xml not found".to_string()))?; let mut xml_content = String::new(); container_file.read_to_string(&mut xml_content)?; @@ -156,7 +157,7 @@ impl EpubParser { } } - Err(CodexError::ParseError( + Err(ParserError::ParseError( "Could not find rootfile path in container.xml".to_string(), )) } @@ -217,7 +218,7 @@ impl EpubParser { ) -> Result<(HashMap<String, (String, String)>, Vec<(String, String)>)> { let mut opf_file = archive .by_name(opf_path) - .map_err(|_| CodexError::ParseError(format!("OPF file not found: {}", opf_path)))?; + .map_err(|_| ParserError::ParseError(format!("OPF file not found: {}", opf_path)))?; let mut xml_content = String::new(); opf_file.read_to_string(&mut xml_content)?; @@ -349,9 +350,9 @@ impl FormatParser for EpubParser { // Read OPF content for metadata extraction let opf_content = { - let mut opf_file = archive - .by_name(&opf_path) - .map_err(|_| CodexError::ParseError(format!("OPF file not found: {}", opf_path)))?; + let mut opf_file = archive.by_name(&opf_path).map_err(|_| { + ParserError::ParseError(format!("OPF file not found: {}", opf_path)) + })?; let mut content = String::new(); opf_file.read_to_string(&mut content)?; content diff --git a/crates/codex-parsers/src/error.rs b/crates/codex-parsers/src/error.rs new file mode 100644 index 00000000..199ca964 --- /dev/null +++ b/crates/codex-parsers/src/error.rs @@ -0,0 +1,20 @@ +//! Error types for file-format parsing. + +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum ParserError { + #[error("IO error: {0}")] + Io(#[from] std::io::Error), + + #[error("ZIP error: {0}")] + Zip(#[from] zip::result::ZipError), + + #[error("Parse error: {0}")] + ParseError(String), + + #[error("Unsupported file format: {0}")] + UnsupportedFormat(String), +} + +pub type Result<T> = std::result::Result<T, ParserError>; diff --git a/crates/codex-parsers/src/isbn_utils.rs b/crates/codex-parsers/src/isbn_utils.rs index fcf392b3..2fd31446 100644 --- a/crates/codex-parsers/src/isbn_utils.rs +++ b/crates/codex-parsers/src/isbn_utils.rs @@ -18,7 +18,7 @@ use std::sync::OnceLock; /// # Examples /// /// ``` -/// use codex::parsers::isbn_utils::clean_isbn; +/// use codex_parsers::isbn_utils::clean_isbn; /// /// assert_eq!(clean_isbn("978-0-123-45678-9"), "9780123456789"); /// assert_eq!(clean_isbn("0-123-45678-X"), "012345678X"); @@ -43,7 +43,7 @@ pub fn clean_isbn(isbn: &str) -> String { /// # Examples /// /// ``` -/// use codex::parsers::isbn_utils::is_valid_isbn; +/// use codex_parsers::isbn_utils::is_valid_isbn; /// /// assert!(is_valid_isbn("9780123456789")); /// assert!(is_valid_isbn("012345678X")); @@ -76,7 +76,7 @@ pub fn is_valid_isbn(isbn: &str) -> bool { /// # Examples /// /// ``` -/// use codex::parsers::isbn_utils::validate_isbn10_checksum; +/// use codex_parsers::isbn_utils::validate_isbn10_checksum; /// /// assert!(validate_isbn10_checksum("0306406152")); /// assert!(validate_isbn10_checksum("043942089X")); @@ -116,7 +116,7 @@ pub fn validate_isbn10_checksum(isbn: &str) -> bool { /// # Examples /// /// ``` -/// use codex::parsers::isbn_utils::validate_isbn13_checksum; +/// use codex_parsers::isbn_utils::validate_isbn13_checksum; /// /// assert!(validate_isbn13_checksum("9780306406157")); /// assert!(validate_isbn13_checksum("9780134685991")); @@ -177,7 +177,7 @@ fn isbn_regex() -> &'static Regex { /// # Examples /// /// ``` -/// use codex::parsers::isbn_utils::extract_isbns; +/// use codex_parsers::isbn_utils::extract_isbns; /// /// let text = "ISBN: 978-0-306-40615-7 and ISBN-10: 0-306-40615-2"; /// let isbns = extract_isbns(text, false); diff --git a/crates/codex-parsers/src/lib.rs b/crates/codex-parsers/src/lib.rs index 77b51891..4959a793 100644 --- a/crates/codex-parsers/src/lib.rs +++ b/crates/codex-parsers/src/lib.rs @@ -1,14 +1,15 @@ //! Codex file-format parsers (CBZ, CBR, EPUB, PDF) and shared metadata //! utilities. //! -//! Depends on `codex-utils` for the `CodexError` / `Result` types and the -//! file-level hasher. No upward deps to db/services/api. +//! Owns its own [`ParserError`] / [`Result`] types. Depends on `codex-utils` +//! only for the file-level hasher. No upward deps to db/services/api. #[cfg(feature = "rar")] pub mod cbr; pub mod cbz; pub mod comic_info; pub mod epub; +pub mod error; pub mod image_utils; pub mod isbn_utils; pub mod metadata; @@ -18,4 +19,5 @@ pub mod series_json; pub mod traits; pub use comic_info::parse_comic_info; +pub use error::{ParserError, Result}; pub use metadata::*; diff --git a/crates/codex-parsers/src/opf.rs b/crates/codex-parsers/src/opf.rs index e6dbf0ce..08c7a6ca 100644 --- a/crates/codex-parsers/src/opf.rs +++ b/crates/codex-parsers/src/opf.rs @@ -4,8 +4,8 @@ //! Used for both embedded EPUB OPF content and Calibre sidecar `metadata.opf` files. use crate::ComicInfo; +use crate::error::{ParserError, Result}; use crate::isbn_utils::extract_isbns; -use codex_utils::{CodexError, Result}; use serde::Serialize; use std::path::Path; @@ -60,7 +60,7 @@ pub fn parse_opf_metadata(xml: &str) -> Result<OpfMetadata> { /// Read and parse an OPF file from disk. pub fn parse_opf_file(path: &Path) -> Result<OpfMetadata> { let content = std::fs::read_to_string(path).map_err(|e| { - CodexError::ParseError(format!("Failed to read OPF file {}: {}", path.display(), e)) + ParserError::ParseError(format!("Failed to read OPF file {}: {}", path.display(), e)) })?; parse_opf_metadata(&content) } diff --git a/crates/codex-parsers/src/pdf/parser.rs b/crates/codex-parsers/src/pdf/parser.rs index 844030f4..c9a0b730 100644 --- a/crates/codex-parsers/src/pdf/parser.rs +++ b/crates/codex-parsers/src/pdf/parser.rs @@ -1,9 +1,10 @@ +use crate::error::{ParserError, Result}; use crate::isbn_utils::extract_isbns; use crate::pdf::renderer; use crate::traits::FormatParser; use crate::{BookMetadata, FileFormat, ImageFormat, PageInfo}; use chrono::{DateTime, Utc}; -use codex_utils::{CodexError, Result, hash_file}; +use codex_utils::hash_file; use image::GenericImageView; use lopdf::{Document, Object, ObjectId}; use std::path::Path; @@ -285,7 +286,7 @@ impl FormatParser for PdfParser { // Load the PDF document with lopdf let doc = Document::load(path) - .map_err(|e| CodexError::ParseError(format!("Failed to load PDF: {}", e)))?; + .map_err(|e| ParserError::ParseError(format!("Failed to load PDF: {}", e)))?; // Extract ISBNs from PDF metadata let isbns = Self::extract_isbns_from_pdf(&doc); diff --git a/crates/codex-parsers/src/series_json.rs b/crates/codex-parsers/src/series_json.rs index 7d2a161a..6f6df15a 100644 --- a/crates/codex-parsers/src/series_json.rs +++ b/crates/codex-parsers/src/series_json.rs @@ -3,7 +3,7 @@ //! Parses Mylar's `series.json` sidecar files (schema version 1.0.2) to extract //! series-level metadata such as publisher, year, description, and status. -use codex_utils::{CodexError, Result}; +use crate::error::{ParserError, Result}; use serde::Deserialize; use std::path::Path; @@ -71,14 +71,14 @@ pub struct MylarCollects { /// Parse series.json content from a string. pub fn parse_series_json(content: &str) -> Result<MylarSeriesMetadata> { let wrapper: MylarSeriesJson = serde_json::from_str(content) - .map_err(|e| CodexError::ParseError(format!("Failed to parse series.json: {}", e)))?; + .map_err(|e| ParserError::ParseError(format!("Failed to parse series.json: {}", e)))?; Ok(wrapper.metadata) } /// Read and parse a series.json file from disk. pub fn parse_series_json_file(path: &Path) -> Result<MylarSeriesMetadata> { let content = std::fs::read_to_string(path).map_err(|e| { - CodexError::ParseError(format!( + ParserError::ParseError(format!( "Failed to read series.json file {}: {}", path.display(), e diff --git a/crates/codex-parsers/src/traits.rs b/crates/codex-parsers/src/traits.rs index 6b49b360..7dcf528d 100644 --- a/crates/codex-parsers/src/traits.rs +++ b/crates/codex-parsers/src/traits.rs @@ -5,7 +5,7 @@ #![allow(dead_code)] use crate::BookMetadata; -use codex_utils::Result; +use crate::error::Result; use std::path::Path; /// Trait for parsing different file formats diff --git a/crates/codex-scanner/src/analyzer.rs b/crates/codex-scanner/src/analyzer.rs index 3b8bd60e..4f976f85 100644 --- a/crates/codex-scanner/src/analyzer.rs +++ b/crates/codex-scanner/src/analyzer.rs @@ -4,9 +4,9 @@ use codex_parsers::BookMetadata; use codex_parsers::cbr::CbrParser; use codex_parsers::cbz::CbzParser; use codex_parsers::epub::EpubParser; +use codex_parsers::error::{ParserError, Result}; use codex_parsers::pdf::PdfParser; use codex_parsers::traits::FormatParser; -use codex_utils::{CodexError, Result}; use std::path::Path; /// Analyze a file and extract metadata @@ -15,7 +15,7 @@ pub fn analyze_file<P: AsRef<Path>>(path: P) -> Result<BookMetadata> { // Detect format let format = detect_format(path) - .ok_or_else(|| CodexError::UnsupportedFormat(path.to_string_lossy().to_string()))?; + .ok_or_else(|| ParserError::UnsupportedFormat(path.to_string_lossy().to_string()))?; // Select appropriate parser let metadata = match format { @@ -30,7 +30,7 @@ pub fn analyze_file<P: AsRef<Path>>(path: P) -> Result<BookMetadata> { } #[cfg(not(feature = "rar"))] codex_parsers::FileFormat::CBR => { - return Err(CodexError::UnsupportedFormat( + return Err(ParserError::UnsupportedFormat( "CBR support requires the 'rar' feature to be enabled".to_string(), )); } @@ -67,7 +67,7 @@ mod tests { let result = analyze_file(&path); assert!(result.is_err()); - if let Err(CodexError::UnsupportedFormat(msg)) = result { + if let Err(ParserError::UnsupportedFormat(msg)) = result { assert!(msg.contains(".txt")); } else { panic!("Expected UnsupportedFormat error"); diff --git a/crates/codex-utils/Cargo.toml b/crates/codex-utils/Cargo.toml index 1866640b..57128230 100644 --- a/crates/codex-utils/Cargo.toml +++ b/crates/codex-utils/Cargo.toml @@ -22,16 +22,12 @@ argon2 = "0.5" base64 = "0.22" chrono-tz = "0.10" cron = "0.13" -image = { version = "0.25", features = ["avif"] } jsonwebtoken = { version = "10", features = ["aws_lc_rs"] } md-5 = "0.10" -quick-xml = { version = "0.39", features = ["serialize"] } rand = "0.10" serde_json = "1.0" sha2 = "0.10" -thiserror = "2.0" unicode-normalization = "0.1" -zip = "8.1" [dev-dependencies] tempfile = { workspace = true } diff --git a/crates/codex-utils/src/deadline.rs b/crates/codex-utils/src/deadline.rs index 2197bf08..64ca031f 100644 --- a/crates/codex-utils/src/deadline.rs +++ b/crates/codex-utils/src/deadline.rs @@ -55,7 +55,7 @@ impl<T, E> DeadlineResult<T, E> { /// # Examples /// /// ```text -/// use codex::utils::deadline::{with_deadline, DeadlineResult}; +/// use codex_utils::deadline::{with_deadline, DeadlineResult}; /// /// let result = with_deadline(5, async { /// // Some database operation diff --git a/crates/codex-utils/src/error.rs b/crates/codex-utils/src/error.rs deleted file mode 100644 index 77d5ad96..00000000 --- a/crates/codex-utils/src/error.rs +++ /dev/null @@ -1,39 +0,0 @@ -//! Error types for the Codex application -//! -//! TODO: Remove allow(dead_code) once all error variants are used - -#![allow(dead_code)] - -use thiserror::Error; - -#[derive(Error, Debug)] -pub enum CodexError { - #[error("IO error: {0}")] - Io(#[from] std::io::Error), - - #[error("ZIP error: {0}")] - Zip(#[from] zip::result::ZipError), - - #[error("Image error: {0}")] - Image(#[from] image::ImageError), - - #[error("XML parsing error: {0}")] - Xml(#[from] quick_xml::DeError), - - #[error("JSON serialization error: {0}")] - Json(#[from] serde_json::Error), - - #[error("Unsupported file format: {0}")] - UnsupportedFormat(String), - - #[error("File not found: {0}")] - FileNotFound(String), - - #[error("Invalid metadata: {0}")] - InvalidMetadata(String), - - #[error("Parse error: {0}")] - ParseError(String), -} - -pub type Result<T> = std::result::Result<T, CodexError>; diff --git a/crates/codex-utils/src/lib.rs b/crates/codex-utils/src/lib.rs index f0e793be..ad9759d4 100644 --- a/crates/codex-utils/src/lib.rs +++ b/crates/codex-utils/src/lib.rs @@ -1,13 +1,12 @@ //! Codex utility helpers shared across the workspace. //! -//! Pure helpers (hashing, password, cron parsing, jwt, error type, custom -//! serde adapters, natural sort, unicode normalization). Depends only on -//! `codex-models` for the `UserRole` type used by `jwt`. +//! Pure helpers (hashing, password, cron parsing, jwt, custom serde adapters, +//! natural sort, unicode normalization). Depends only on `codex-models` for +//! the `UserRole` type used by `jwt`. pub mod credential_encryption; pub mod cron; pub mod deadline; -pub mod error; pub mod hasher; pub mod json; pub mod jwt; @@ -18,7 +17,6 @@ pub mod serde; #[allow(unused_imports)] pub use deadline::{DeadlineResult, with_deadline, with_deadline_or_err}; -pub use error::{CodexError, Result}; pub use hasher::hash_file; pub use json::{ json_merge_patch, parse_custom_metadata, serialize_custom_metadata, diff --git a/crates/codex-utils/src/natural_sort.rs b/crates/codex-utils/src/natural_sort.rs index 19efc7a4..9979fa79 100644 --- a/crates/codex-utils/src/natural_sort.rs +++ b/crates/codex-utils/src/natural_sort.rs @@ -15,7 +15,7 @@ use std::cmp::Ordering; /// # Examples /// /// ```ignore -/// use codex::utils::natural_sort::natural_cmp; +/// use codex_utils::natural_sort::natural_cmp; /// /// assert_eq!(natural_cmp("Vol. 2", "Vol. 10"), Ordering::Less); /// assert_eq!(natural_cmp("Ch 1", "Ch 1"), Ordering::Equal); diff --git a/src/commands/migrate.rs b/src/commands/migrate.rs index 3c9c2983..4cd39389 100644 --- a/src/commands/migrate.rs +++ b/src/commands/migrate.rs @@ -1,5 +1,5 @@ -use crate::commands::common::{display_database_config, init_tracing, load_config}; use anyhow::{Context, Result}; +use codex_cli_common::{display_database_config, init_tracing, load_config}; use codex_db::Database; use std::path::PathBuf; use tracing::info; diff --git a/src/commands/mod.rs b/src/commands/mod.rs index eeb6f5d1..33f9ba9c 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -1,4 +1,3 @@ -pub mod common; pub mod migrate; pub mod openapi; pub mod scan; diff --git a/src/commands/serve.rs b/src/commands/serve.rs index 1993be30..98587990 100644 --- a/src/commands/serve.rs +++ b/src/commands/serve.rs @@ -1,4 +1,4 @@ -use crate::commands::common::{ +use codex_cli_common::{ TracingHandles, display_database_config, ensure_data_directories, get_worker_count, init_database, init_settings_service, init_tracing, load_config, shutdown_workers, spawn_workers, diff --git a/src/commands/tasks.rs b/src/commands/tasks.rs index 93c6b6f0..f4ed8b53 100644 --- a/src/commands/tasks.rs +++ b/src/commands/tasks.rs @@ -5,7 +5,7 @@ use sea_orm::{ColumnTrait, DatabaseConnection, EntityTrait, PaginatorTrait, Quer use std::path::PathBuf; use uuid::Uuid; -use crate::commands::common::{init_database, load_config}; +use codex_cli_common::{init_database, load_config}; use codex_db::entities::prelude::Tasks; use codex_db::entities::tasks; use codex_db::repositories::TaskRepository; diff --git a/src/commands/wait_for_migrations.rs b/src/commands/wait_for_migrations.rs index 98c6a80a..bf6ee696 100644 --- a/src/commands/wait_for_migrations.rs +++ b/src/commands/wait_for_migrations.rs @@ -1,7 +1,7 @@ -use crate::commands::common::{ +use anyhow::Result; +use codex_cli_common::{ display_database_config, init_tracing, load_config, wait_for_migrations_complete, }; -use anyhow::Result; use std::path::PathBuf; use tracing::info; diff --git a/src/commands/worker.rs b/src/commands/worker.rs index da6d45c2..8a9c2bb9 100644 --- a/src/commands/worker.rs +++ b/src/commands/worker.rs @@ -1,4 +1,4 @@ -use crate::commands::common::{ +use codex_cli_common::{ TracingHandles, display_database_config, ensure_data_directories, get_worker_count, init_database, init_settings_service, init_tracing, load_config, shutdown_workers, spawn_workers, From c96c18d8ec68d8697d8ef28dd1fbc70af82b70ef Mon Sep 17 00:00:00 2001 From: Sylvain Cau <ashdevfr@gmail.com> Date: Sun, 24 May 2026 09:27:16 -0700 Subject: [PATCH 14/14] docs(workspace): add workspace architecture section and ADR 0001 Document the post-split layout so contributors landing on the repo can understand the crate boundaries without reading the implementation log. - architecture.md gains a "Workspace Architecture" section with an ASCII layering diagram of the 13 sibling crates, a per-crate reference table, build/feature-flag examples, and a link to the ADR. - New docs/dev/decisions/0001-workspace-split.md captures the rationale: the original directional dependency matrix, the incremental rollout with measurement gates, the cumulative warm-rebuild improvement, costs accepted, and alternatives considered (stay single-crate with sccache, one giant core crate, layered internal-api split, publish to crates.io). - devSidebar.ts adds a "Decisions" category and wires the ADR in. Explicit id/slug frontmatter preserves the numeric prefix through Docusaurus's default prefix stripping so both the doc id and the URL retain the ADR number. --- docs/dev/contributing/architecture.md | 97 +++++++++++ docs/dev/decisions/0001-workspace-split.md | 192 +++++++++++++++++++++ docs/devSidebar.ts | 8 + 3 files changed, 297 insertions(+) create mode 100644 docs/dev/decisions/0001-workspace-split.md diff --git a/docs/dev/contributing/architecture.md b/docs/dev/contributing/architecture.md index a675cb07..d15f2efe 100644 --- a/docs/dev/contributing/architecture.md +++ b/docs/dev/contributing/architecture.md @@ -9,6 +9,103 @@ This document describes the architecture and design decisions behind Codex. Codex is built with Rust for performance and safety. It follows a modular architecture that separates concerns and enables horizontal scaling. +## Workspace Architecture + +The backend is a Cargo workspace. The root `codex` crate produces the binary and contains only `src/main.rs` plus the per-subcommand orchestrators under `src/commands/`. Every subsystem is its own sibling crate under `crates/`, so editing one subsystem only recompiles that crate and its downstream consumers, keeping warm rebuilds fast. + +### Crate Layering + +Each crate sits at a fixed level in the dependency graph. Crates may only depend on crates lower in the stack (or peers on the same level when the edge is non-cyclic). The binary at the top wires everything together. + +``` +┌────────────────────────────────────────────────────────────┐ +│ codex (bin) main.rs + commands/ │ +│ codex-cli-common shared subcommand helpers │ +└────────────────────────────────────────────────────────────┘ + │ +┌────────────────────────────────────────────────────────────┐ +│ codex-api axum, OPDS, OPDS2, Komga, KOReader │ +│ observability, embedded frontend │ +└────────────────────────────────────────────────────────────┘ + │ +┌────────────────────────────────────────────────────────────┐ +│ codex-scheduler cron / interval scheduler │ +└────────────────────────────────────────────────────────────┘ + │ +┌──────────────────┐ ┌──────────────────┐ ┌──────────────┐ +│ codex-tasks │ │ codex-scanner │ │ codex-search │ +│ background jobs │ │ library scan │ │ fuzzy index │ +└──────────────────┘ └──────────────────┘ └──────────────┘ + │ +┌────────────────────────────────────────────────────────────┐ +│ codex-services business logic, plugins, metadata │ +└────────────────────────────────────────────────────────────┘ + │ +┌────────────────────────────────────────────────────────────┐ +│ codex-db SeaORM entities + repositories │ +└────────────────────────────────────────────────────────────┘ + │ +┌────────────────────────────────────────────────────────────┐ +│ codex-parsers CBZ / CBR / EPUB / PDF │ +└────────────────────────────────────────────────────────────┘ + │ +┌──────────────────┐ ┌──────────────────┐ ┌──────────────┐ +│ codex-utils │ │ codex-events │ │ codex-config │ +│ crypto, jwt, │ │ in-process event │ │ YAML + env │ +│ hashing helpers │ │ broadcaster │ │ overrides │ +└──────────────────┘ └──────────────────┘ └──────────────┘ + │ +┌────────────────────────────────────────────────────────────┐ +│ codex-models shared DTOs + cross-layer types │ +└────────────────────────────────────────────────────────────┘ +``` + +`migration/` is a self-contained sibling crate consumed by `codex-db` for SeaORM schema migrations. + +### Crate Reference + +| Crate | Purpose | +| --- | --- | +| `codex` (bin) | CLI entry point (`main.rs`) and subcommand orchestrators (`commands/scan.rs`, `commands/serve.rs`, `commands/worker.rs`, ...) | +| `codex-cli-common` | Shared CLI helpers: config loading, tracing init, database init, worker spawn/shutdown | +| `codex-api` | HTTP layer (axum), native `/api/v1/`, OPDS 1.2/2.0, Komga compatibility, KOReader sync, observability HTTP layers, embedded frontend | +| `codex-scheduler` | Cron- and interval-based scheduler that reconciles plugin-defined recurring tasks | +| `codex-tasks` | Background worker and task handlers (scans, releases, OAuth refresh, ...) | +| `codex-scanner` | Library scan workflow: file discovery, deduplication, analysis pipeline | +| `codex-search` | In-memory fuzzy search index, kept in sync via the event broadcaster | +| `codex-services` | Business logic: auth, plugins, metadata, release tracking, exports, OTel meter instruments | +| `codex-db` | SeaORM entities, repositories, and connection pool | +| `codex-parsers` | Format parsers (CBZ, CBR optional behind `rar`, EPUB, PDF) and their format-scoped `ParserError` | +| `codex-utils` | Format-agnostic helpers: crypto, JWT, password hashing, file/zip helpers, deadlines | +| `codex-events` | In-process event broadcaster (entity changes, task lifecycle, releases) | +| `codex-config` | YAML config loader with environment-variable overrides | +| `codex-models` | Pure-leaf DTOs and cross-layer types (permissions, sort/filter primitives, task types, plugin protocol) | +| `migration/` | SeaORM migrations, depended on directly by `codex-db` | + +### Building Individual Crates + +Because each subsystem is its own crate, you can build, test, and lint them in isolation: + +```bash +cargo build -p codex-db +cargo test -p codex-parsers +cargo clippy -p codex-api -- -D warnings +``` + +The full workspace is built and tested with `cargo build --workspace` and `make test-fast` (which already passes `--workspace` to nextest). + +### Feature Flags + +Three feature flags cascade from the root binary through the sibling crates: + +- `rar` (default on) — enables CBR parsing via the proprietary UnRAR library. Owned by `codex-parsers`; forwarded by `codex-scanner`, `codex-services`, `codex-tasks`, `codex-api`, and the root crate. +- `observability` (default on) — enables OpenTelemetry tracing, metrics, and the HTTP middleware that emits them. Owned by `codex-services` (meter instruments), `codex-api` (HTTP layers), and `codex-cli-common` (tracing-subscriber composition). +- `embed-frontend` — bundles the built React frontend into the binary via `rust-embed`. Owned by `codex-api`. + +### Design Rationale + +The workspace split is documented in detail in [ADR 0001: Workspace Split](../decisions/0001-workspace-split.md), which captures the original dependency graph, the measured build-time outcomes, and the alternatives considered. + ## Core Principles ### Stateless Design diff --git a/docs/dev/decisions/0001-workspace-split.md b/docs/dev/decisions/0001-workspace-split.md new file mode 100644 index 00000000..6861b79b --- /dev/null +++ b/docs/dev/decisions/0001-workspace-split.md @@ -0,0 +1,192 @@ +--- +id: 0001-workspace-split +slug: 0001-workspace-split +--- + +# ADR 0001: Workspace Split + +- **Status:** Accepted +- **Date:** 2026-05-23 +- **Authors:** Sylvain Cau + +## Context + +Codex had grown to roughly 200k lines of Rust in a single crate. Editing one subsystem (for example, a file in `src/parsers/`) forced the entire library to re-typecheck and the binary to relink, even when no API handler was affected. + +A build-performance session on 2026-05-22 picked off the easy wins: + +- Excluded `target/` from Spotlight indexing (large, immediate win on macOS). +- Adopted `sccache` as an opt-in `RUSTC_WRAPPER` (~10% on cold builds). +- Consolidated the integration test layout from 13 binaries down to 1. + +With those in place, warm rebuilds still cost about **30 seconds** after a single-line edit, and the remaining structural lever was the single-crate cargo cache scope. Crate-level caching only helps when consumers of a changed crate are themselves in separate crates, so a workspace split was the next reasonable step. + +### Dependency Graph at the Time + +The audit on 2026-05-22 counted directional imports between the 12 top-level `src/` subdirectories. Rows import from columns, measured by `use crate::<col>` references: + +``` +FROM \ TO api commands config db events parsers scanner scheduler search services tasks utils +api - 0 1 52 9 2 3 0 0 16 13 11 +commands 2 - 3 4 1 1 1 0 0 2 1 1 +config 0 0 - 0 0 0 0 0 0 0 0 0 +db 3 0 2 - 4 0 0 0 0 7 1 7 +events 0 0 0 0 - 0 0 0 0 0 0 0 +parsers 0 0 0 0 0 - 0 0 0 0 0 7 +scanner 0 0 0 3 2 4 - 0 0 1 2 3 +scheduler 0 0 0 2 0 0 1 - 0 2 2 2 +search 0 0 0 2 1 0 0 0 - 0 0 1 +services 3 0 6 28 9 0 0 1 0 - 2 3 +tasks 0 0 5 32 29 0 2 0 0 26 - 0 +utils 1 0 0 0 0 0 0 0 0 0 0 - +``` + +Key observations from the matrix: + +- **Pure leaves (zero outbound edges):** `config`, `events`. Trivially extractable. +- **Near-leaf:** `utils → api` (1 file). +- **Six small cycles, all ≤7 files:** `utils ↔ api`, `db ↔ api` (3 files), `services ↔ api` (3 files), `db ↔ services` (7 files), `services ↔ tasks` (2 files), `services ↔ scheduler` (1 file). +- **Top of the stack:** `commands` (binary orchestrator). + +The cycles were drift, not structural fact: in every case the wrong-direction import was a shared type that had landed in the wrong layer. Eliminating them was independently valuable even if the workspace split never happened. + +## Decision + +Split the single `codex` library crate into a Cargo workspace of layered sibling crates, rolled out incrementally with explicit decision gates after the first measurement. + +### Principles + +- **Incremental, not big-bang.** Each phase is a separate commit set. Any phase can be the last one; the work done so far is never wasted. +- **Decision gates at Phase 2 (workspace mechanics work) and Phase 3 (measured win materializes).** Both have pass/fail criteria stated up front. +- **Phase 1 cleanup happens regardless.** Even if no further phase shipped, the drift cleanup would have been worth it. +- **Workspace-internal, not published.** All sibling crates use `version = "0.0.0"` and `publish = false`. Codex is not a library distributed via crates.io. +- **`migration/` stays as-is.** It was already a separate crate and is self-contained; `codex-db` simply depends on it. +- **DTOs live in `codex-models`** to break the `api ↔ db` cycles cleanly. +- **Tests stay in `tests/it.rs`.** Each new crate may grow its own `#[cfg(test)]` blocks, but the integration test binary stays consolidated. + +### Final Layering + +``` +┌────────────────────────────────────────────────────────────┐ +│ codex (bin) main.rs + commands/ │ +│ codex-cli-common shared subcommand helpers │ +└────────────────────────────────────────────────────────────┘ + │ +┌────────────────────────────────────────────────────────────┐ +│ codex-api axum, OPDS, OPDS2, Komga, KOReader │ +│ observability, embedded frontend │ +└────────────────────────────────────────────────────────────┘ + │ +┌────────────────────────────────────────────────────────────┐ +│ codex-scheduler cron / interval scheduler │ +└────────────────────────────────────────────────────────────┘ + │ +┌──────────────────┐ ┌──────────────────┐ ┌──────────────┐ +│ codex-tasks │ │ codex-scanner │ │ codex-search │ +└──────────────────┘ └──────────────────┘ └──────────────┘ + │ +┌────────────────────────────────────────────────────────────┐ +│ codex-services business logic, plugins, metadata │ +└────────────────────────────────────────────────────────────┘ + │ +┌────────────────────────────────────────────────────────────┐ +│ codex-db SeaORM entities + repositories │ +└────────────────────────────────────────────────────────────┘ + │ +┌────────────────────────────────────────────────────────────┐ +│ codex-parsers CBZ / CBR / EPUB / PDF │ +└────────────────────────────────────────────────────────────┘ + │ +┌──────────────────┐ ┌──────────────────┐ ┌──────────────┐ +│ codex-utils │ │ codex-events │ │ codex-config │ +└──────────────────┘ └──────────────────┘ └──────────────┘ + │ +┌────────────────────────────────────────────────────────────┐ +│ codex-models shared DTOs + cross-layer types │ +└────────────────────────────────────────────────────────────┘ +``` + +`migration/` is consumed by `codex-db` and depends on no other Codex crate. + +### Rollout + +| Phase | Outcome | +| ----- | ------- | +| 1 | Drift cleanup. Six cycles removed; single-crate build stayed green. | +| 2 | Workspace bootstrap: `codex-config` and `codex-events` extracted as leaf crates. | +| 3 | `codex-models`, `codex-utils`, `codex-parsers` extracted. **MAYBE gate (~7% warm-rebuild improvement)** — proceeded based on the structural argument that the leaves were too small to move the needle. | +| 4 | `codex-db` extracted. **GO gate (~26% cumulative warm-rebuild improvement vs Phase 2 baseline).** | +| 5 | Business layers extracted: `codex-services`, `codex-search`, `codex-scanner`, `codex-tasks`, `codex-scheduler`. **GO gate (~50% cumulative).** | +| 6 | `codex-api` extracted; root crate slimmed to `main.rs` + `commands/`. **GO gate (~62% cumulative warm-rebuild improvement).** | +| 7 | `CodexError` cleanup: moved to `codex-parsers::ParserError`, dropping `image`, `quick-xml`, `zip`, and `thiserror` from `codex-utils`'s dep list. | +| 8 | `codex-cli-common` extracted from `src/commands/common.rs` for architectural consistency. | + +## Consequences + +### Build Times + +End-to-end measurements, using `cargo clean && cargo test --no-run` for cold and `touch <file> && cargo test --no-run` for warm. The warm-edit target is `src/api/routes/v1/handlers/auth.rs` (a representative API handler). + +| Metric | Pre-split (Phase 2 baseline) | Post-split (Phase 6) | Δ | +| --- | --- | --- | --- | +| Cold (`cargo clean` + `cargo test --no-run`) | 191.8s | 133.3s | **−30.5%** | +| Warm (one-line edit in an API handler) | ~29.7s | ~11.3s | **−62.0%** | +| Warm (one-line edit in `src/commands/`) | n/a | ~2.8s (root binary only) | new fast path | + +The dominant gain came from extracting `codex-db` (Phase 4, ~21% incremental) and the business layers (Phase 5, ~32% incremental). Phase 6 (`codex-api`) added a final ~24% on top. Leaf-only extractions (Phases 2–3) moved the needle by less than 10% combined, which matched the prediction that the leaves were too small to dominate the warm-rebuild cost. + +The sea-orm and utoipa macro re-derivation cost (flagged as the dominant Phase 3 risk) did not materialize. Each crate pays the macro cost only when it is itself recompiled; the cost no longer cascades into the consumers. + +### Crate Isolation (the structural payoff) + +- Editing `crates/codex-api/src/routes/v1/handlers/auth.rs` recompiles only `codex-api` and the root binary. None of the other 11 workspace crates rebuild. +- Editing `src/commands/scan.rs` recompiles only the root binary in under three seconds. Even `codex-api` stays cached. +- Editing `crates/codex-scheduler/src/lib.rs` recompiles only `codex-scheduler` and the root binary; `codex-services`, `codex-scanner`, and `codex-tasks` (all dependencies of scheduler) stay cached. + +This is the property `cargo test -p <crate>` exploits: TDD cycles for a specific subsystem can now skip the rest of the workspace entirely. + +### Costs Accepted + +- **Cold-build metadata overhead.** Each sibling crate adds dep-graph metadata. The +1.6% cold delta after Phase 2 was the most visible point; cumulative cold builds are still faster than pre-split because crate-level sccache hits offset the metadata cost. +- **rust-analyzer cold-index time.** Slightly longer the first time a checkout is opened. Acceptable. +- **Trait abstractions for would-be cycles.** `services → scheduler` was broken by introducing `SharedSchedulerReconciler` (a boxed-future trait); the scheduler crate provides the concrete impl, `commands/serve.rs` wires it up. The indirection adds one dyn dispatch per scheduler reconciliation, which is not a hot path. +- **`pub` audit churn.** Two `EpubParser` helpers had to be promoted from `pub(crate)` to `pub` to keep working across the crate boundary (Phase 3). All other phases hit zero visibility promotions. +- **Build-time version propagation.** `env!("CARGO_PKG_VERSION")` resolves to a sub-crate's `0.0.0` when called from inside `codex-api`. Fixed in two places: the `info::get_app_info` handler reads name/version from `AppState` (filled by the binary at startup), and the `utoipa::OpenApi` derive picks up `CODEX_BIN_VERSION` from a tiny `crates/codex-api/build.rs` that reads the root `Cargo.toml`. + +### Tooling Impact + +- **`cargo-dist`:** unchanged. `cargo dist plan` continues to emit only the `codex` binary across the same five targets after every phase. +- **`Makefile`:** `make test-fast` and friends now pass `--workspace` to `cargo nextest` so leaf-crate tests are not silently skipped. Discovered when Phase 4 surfaced ~540 missing `codex-db` tests in the nextest report. +- **OpenAPI generation:** `make openapi` works unchanged; the spec correctly reports the binary's version after the `build.rs` trick above. +- **CI:** no `.github/` workflow changes were required; CI already builds via `cargo build`/`cargo test` at the workspace root. + +## Alternatives Considered + +### Stay single-crate, lean harder on `sccache` and incremental compilation + +This is what the 2026-05-22 perf session did. It captured the easy wins (Spotlight exclusion, `sccache`, test consolidation) and brought warm rebuilds from ~35s to ~30s. After those, there was no further single-crate lever: the warm rebuild was dominated by `rustc` re-typechecking the whole library before linking. + +The 62% warm-rebuild improvement from the workspace split is roughly 6x what sccache alone delivered on this codebase. Staying single-crate was a real option, but the headroom was effectively zero. + +### One giant `codex-core` crate with internal `mod`s + +This would have been the smallest delta from the single-crate layout: keep one crate, but reorganize modules. It would not have helped build times at all, since cargo caches at crate granularity, not module granularity. Rejected on the grounds that the cost of the rename was non-trivial and the benefit was zero. + +### Layered "internal API" crates (e.g., `codex-api` + `codex-api-impl`) + +Sometimes used in larger Rust workspaces to give one crate's type-checking pressure a fast path. Rejected as premature: the simpler one-crate-per-subsystem layering already produced the warm-rebuild target with far less indirection. Worth reconsidering only if a specific crate later becomes a build-time bottleneck. + +### Publish sibling crates to crates.io + +A common reason to split a workspace. Not relevant here: Codex is a deployed binary, not a library, and there is no third-party consumer for individual subsystem crates. Keeping `publish = false` on every sibling avoids semver maintenance overhead. + +## Follow-Ups + +- A `codex-sdk` crate that re-exports `codex_models::plugin::*` plus minimal RPC framing helpers, intended for Rust plugin authors. Scope-gated; not yet started. See the implementation plan's Phase 10. +- The `commands/` orchestrators (`migrate.rs`, `serve.rs`, `worker.rs`, ...) stay in the binary crate. They are binary-glue and would not benefit from being moved to a sibling, but they remain a candidate for further structural splitting if the binary ever grows enough to warrant it. + +## References + +- Implementation plan: `tmp/implementation/planned/split-workspace.md` (local working doc, includes per-phase progress notes and measurement runs). +- Development build-time guide: [Development → Speeding Up Builds](../contributing/development.md#speeding-up-builds). +- Architecture overview: [Architecture → Workspace Architecture](../contributing/architecture.md#workspace-architecture). diff --git a/docs/devSidebar.ts b/docs/devSidebar.ts index a92c7bca..f9cc2620 100644 --- a/docs/devSidebar.ts +++ b/docs/devSidebar.ts @@ -24,6 +24,14 @@ const devSidebar: SidebarsConfig = { "contributing/migrations", ], }, + { + type: "category", + label: "Decisions", + collapsed: false, + items: [ + "decisions/0001-workspace-split", + ], + }, ], };