diff --git a/Cargo.lock b/Cargo.lock index 7ed98c4d..87745913 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -811,83 +811,391 @@ dependencies = [ name = "codex" version = "1.29.0" dependencies = [ - "aes-gcm", "anyhow", - "argon2", + "axum", + "base64 0.22.1", + "chrono", + "clap", + "codex-api", + "codex-cli-common", + "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-opentelemetry 0.33.0", + "tracing-subscriber", + "tracing-test", + "utoipa", + "uuid", + "walkdir", + "zip", +] + +[[package]] +name = "codex-api" +version = "1.29.0" +dependencies = [ + "anyhow", "async-stream", "axum", "axum-tracing-opentelemetry", "base64 0.22.1", "chrono", - "chrono-tz", - "clap", - "cron", - "csv", + "codex-config", + "codex-db", + "codex-events", + "codex-models", + "codex-parsers", + "codex-scanner", + "codex-scheduler", + "codex-search", + "codex-services", + "codex-tasks", + "codex-utils", "dashmap", "dirs", "futures", "globset", - "handlebars", "http-body-util", "httpdate", "hyper", "image", + "log", + "migration", + "mime_guess", + "opentelemetry 0.32.0", + "opentelemetry-otlp", + "opentelemetry-semantic-conventions 0.32.0", + "opentelemetry_sdk", + "quick-xml", + "rand 0.10.0", + "reqwest 0.13.2", + "rust-embed", + "sea-orm", + "serde", + "serde_json", + "serial_test", + "tempfile", + "tokio", + "tokio-util", + "tonic", + "tower", + "tower-http", + "tracing", + "tracing-opentelemetry 0.33.0", + "tracing-subscriber", + "urlencoding", + "utoipa", + "utoipa-scalar", + "uuid", + "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" +dependencies = [ + "anyhow", + "serde", + "serde_yaml", + "serial_test", + "tempfile", +] + +[[package]] +name = "codex-db" +version = "1.29.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 = "1.29.0" +dependencies = [ + "chrono", + "serde", + "serde_json", + "tokio", + "tracing", + "utoipa", + "uuid", +] + +[[package]] +name = "codex-models" +version = "1.29.0" +dependencies = [ + "chrono", + "lazy_static", + "serde", + "serde_json", + "utoipa", + "uuid", +] + +[[package]] +name = "codex-parsers" +version = "1.29.0" +dependencies = [ + "anyhow", + "chrono", + "codex-utils", + "image", "infer", - "jsonwebtoken", "jxl-oxide", + "lopdf", + "pdfium-render", + "quick-xml", + "regex", + "resvg", + "serde", + "serde_json", + "tempfile", + "thiserror 2.0.18", + "tracing", + "unrar", + "urlencoding", + "zip", +] + +[[package]] +name = "codex-scanner" +version = "1.29.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-scheduler" +version = "1.29.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 = "1.29.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 = "1.29.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", - "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", - "sea-orm-migration", "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", - "unrar", "urlencoding", "utoipa", - "utoipa-scalar", "uuid", - "walkdir", - "zip", +] + +[[package]] +name = "codex-tasks" +version = "1.29.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 = "1.29.0" +dependencies = [ + "aes-gcm", + "anyhow", + "argon2", + "base64 0.22.1", + "chrono", + "chrono-tz", + "codex-models", + "cron", + "jsonwebtoken", + "md-5", + "rand 0.10.0", + "serde", + "serde_json", + "serial_test", + "sha2", + "tempfile", + "tokio", + "unicode-normalization", + "uuid", ] [[package]] @@ -3068,7 +3376,7 @@ dependencies = [ [[package]] name = "migration" -version = "0.1.0" +version = "1.29.0" dependencies = [ "chrono", "lazy_static", @@ -4678,6 +4986,7 @@ dependencies = [ "proc-macro2", "quote", "rust-embed-utils", + "shellexpand", "syn 2.0.117", "walkdir", ] @@ -5318,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 bc6eddec..ac90a4bd 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" @@ -14,85 +14,120 @@ path = "src/main.rs" [features] default = ["rar", "observability"] -rar = ["dep:unrar"] -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", +# 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", "codex-cli-common/observability"] + +[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 = [".", "migration"] - -[dependencies] -# CLI -clap = { version = "4", features = ["derive"] } +members = [ + ".", + "migration", + "crates/codex-config", + "crates/codex-events", + "crates/codex-models", + "crates/codex-utils", + "crates/codex-parsers", + "crates/codex-db", + "crates/codex-services", + "crates/codex-search", + "crates/codex-scanner", + "crates/codex-tasks", + "crates/codex-scheduler", + "crates/codex-api", + "crates/codex-cli-common", +] -# Serialization +# 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" -serde_json = "1.0" -csv = "1.3" - -# Configuration (config crate removed - not used, config loaded via serde_yaml) - -# 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" - -# Hashing -sha2 = "0.10" -md-5 = "0.10" - -# Error handling -anyhow = "1.0" -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" +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"] } -# File system utilities -walkdir = "2.5" -dirs = "6.0" -globset = "0.4" +# 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" } +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" } + +# Shared dev-dependencies +tempfile = "3.13" +serial_test = "3.2" -# Date/time -chrono = { version = "0.4", features = ["serde"] } -chrono-tz = "0.10" -httpdate = "1.0" +[dependencies] +# CLI +clap = { version = "4", features = ["derive"] } -# Table formatting -tabled = "0.20" +# Workspace-inherited +anyhow = { workspace = true } +chrono = { workspace = true } +serde = { workspace = true } +serde_yaml = { workspace = true } +tokio = { workspace = true } +tracing = { workspace = true } +utoipa = { workspace = true } +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 } +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 } + +# Serialization (commands use json output formats) +serde_json = "1.0" -# Database +# Database (commands inspect DatabaseBackend at runtime to log the active +# driver in init_database). sea-orm = { version = "1.1", features = [ "sqlx-postgres", "sqlx-sqlite", @@ -101,116 +136,56 @@ sea-orm = { version = "1.1", features = [ "with-chrono", "with-uuid", ] } -sea-orm-migration = { version = "1.1", features = [ - "runtime-tokio-rustls", - "sqlx-postgres", - "sqlx-sqlite", -] } -migration = { path = "migration" } -tokio = { version = "1", features = ["full"] } -uuid = { version = "1.0", features = ["v4", "serde"] } -# 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-subscriber = { version = "0.3", features = ["env-filter"] } -tracing-appender = "0.2" -log = "0.4" # For sqlx logging level configuration - -# 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" - -# 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", -] } - -# API Documentation -utoipa = { version = "5.0", features = [ - "axum_extras", - "chrono", - "uuid", - "yaml", -] } -utoipa-scalar = { version = "0.3", features = ["axum"] } -# Job Scheduling -cron = "0.13" -tokio-cron-scheduler = "0.15" +# Async helpers (commands/serve.rs uses tokio_util::sync::CancellationToken) tokio-util = { version = "0.7", features = ["io"] } -# Concurrent data structures -dashmap = "6.1" -lru = "0.18" -parking_lot = "0.12" +# Tabular output (commands/tasks.rs admin views) +tabled = "0.20" -# Fuzzy matching (in-memory search index) -nucleo-matcher = "0.3" +# Axum (`axum::serve` in commands/serve.rs to bind the router on the listener). +axum = { version = "0.8", features = ["multipart"] } -# Static file embedding for frontend -rust-embed = "8.5" -mime_guess = "2.0" +# Recursive file walking (commands/scan.rs) +walkdir = "2.5" [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" +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 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" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } # ============================================================================= # Development Profile - Optimized for fast incremental builds 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> { 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/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-api/Cargo.toml b/crates/codex-api/Cargo.toml new file mode 100644 index 00000000..7d268619 --- /dev/null +++ b/crates/codex-api/Cargo.toml @@ -0,0 +1,160 @@ +[package] +name = "codex-api" +version.workspace = true +edition.workspace = true +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`). +# `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) +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..320789e1 --- /dev/null +++ b/crates/codex-api/build.rs @@ -0,0 +1,45 @@ +//! Surface workspace-root paths/values to the API crate at compile time. +//! +//! 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() { + // Workspace root is two levels up from this crate's manifest dir. + let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let workspace_root = manifest_dir + .parent() + .and_then(|p| p.parent()) + .expect("codex-api should live under /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) + .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}"); + println!( + "cargo:rustc-env=CODEX_WEB_DIST={}", + workspace_root.join("web").join("dist").display() + ); +} diff --git a/src/api/docs.rs b/crates/codex-api/src/docs.rs similarity index 98% rename from src/api/docs.rs rename to crates/codex-api/src/docs.rs index 9ee8c162..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 @@ -638,14 +641,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, @@ -807,7 +810,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, @@ -1002,9 +1005,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, @@ -1035,9 +1038,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/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 90% rename from src/api/extractors/auth.rs rename to crates/codex-api/src/extractors/auth.rs index 86fb4e67..fdfb239c 100644 --- a/src/api/extractors/auth.rs +++ b/crates/codex-api/src/extractors/auth.rs @@ -1,12 +1,12 @@ 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 crate::error::ApiError; +use crate::permissions::{Permission, UserRole}; 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; use std::collections::HashSet; @@ -174,70 +174,70 @@ 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, - pub auth_config: Arc, + pub refresh_token_service: Arc, + pub auth_config: Arc, /// Database configuration - used for operation deadlines and pool settings - pub database_config: Arc, + pub database_config: Arc, /// PDF configuration - used for rendering settings and cache config - pub pdf_config: Arc, + pub pdf_config: Arc, /// 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, - pub email_service: Arc, - pub event_broadcaster: Arc, + pub observability_config: Arc, + pub email_service: Arc, + pub event_broadcaster: Arc, /// Settings service - used for runtime configuration #[allow(dead_code)] - pub settings_service: Arc, - pub thumbnail_service: Arc, + pub settings_service: Arc, + pub thumbnail_service: Arc, /// File cleanup service for managing orphaned files - pub file_cleanup_service: Arc, + pub file_cleanup_service: Arc, /// Task metrics service for collecting task performance data /// None in test environments or when not needed - pub task_metrics_service: Option>, + pub task_metrics_service: Option>, /// 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>>, + pub scheduler: Option>>, /// 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, + pub read_progress_service: Arc, /// 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, + pub auth_tracking_service: Arc, /// 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, + pub pdf_page_cache: Arc, /// 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, + pub pdf_handle_cache: Arc, /// 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, + pub inflight_thumbnails: Arc, /// 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, /// Rate limiter service for API rate limiting /// None when rate limiting is disabled in config - pub rate_limiter_service: Option>, + pub rate_limiter_service: Option>, /// Plugin manager for coordinating external plugin processes /// Manages plugin lifecycle, spawning, and request routing - pub plugin_manager: Arc, + pub plugin_manager: Arc, /// Plugin metrics service for collecting plugin performance data /// Always available (in-memory only, no persistence) - pub plugin_metrics_service: Arc, + pub plugin_metrics_service: Arc, /// OIDC authentication service for external identity provider authentication /// None when OIDC is disabled in config - pub oidc_service: Option>, + pub oidc_service: Option>, /// OAuth state manager for user plugin OAuth flows - pub oauth_state_manager: Arc, + pub oauth_state_manager: Arc, /// Plugin file storage service for managing plugin data directories /// None when not configured (shouldn't happen in normal operation) - pub plugin_file_storage: Option>, + pub plugin_file_storage: Option>, /// Export storage service for managing series export files on disk /// None in test environments or when not configured - pub export_storage: Option>, + pub export_storage: Option>, /// 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. @@ -245,7 +245,14 @@ 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, + pub fuzzy_index: Arc, + /// 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 64% rename from src/api/extractors/mod.rs rename to crates/codex-api/src/extractors/mod.rs index 03fa60bd..eb7117c7 100644 --- a/src/api/extractors/mod.rs +++ b/crates/codex-api/src/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 `codex_services::content_filter`. +pub use codex_services::content_filter::ContentFilter; 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 99% rename from src/api/middleware/rate_limit.rs rename to crates/codex-api/src/middleware/rate_limit.rs index 4b6e6466..f2d89440 100644 --- a/src/api/middleware/rate_limit.rs +++ b/crates/codex-api/src/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"; @@ -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/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 96% rename from src/observability/http.rs rename to crates/codex-api/src/observability/http.rs index dced7b7e..bdd4563f 100644 --- a/src/observability/http.rs +++ b/crates/codex-api/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/inventory.rs b/crates/codex-api/src/observability/inventory.rs similarity index 96% rename from src/observability/inventory.rs rename to crates/codex-api/src/observability/inventory.rs index ec471544..c6e816ce 100644 --- a/src/observability/inventory.rs +++ b/crates/codex-api/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/crates/codex-api/src/observability/mod.rs similarity index 81% rename from src/observability/mod.rs rename to crates/codex-api/src/observability/mod.rs index ce5a0be4..fb277ef3 100644 --- a/src/observability/mod.rs +++ b/crates/codex-api/src/observability/mod.rs @@ -28,12 +28,9 @@ 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"))] -#[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/observability/providers.rs b/crates/codex-api/src/observability/providers.rs similarity index 98% rename from src/observability/providers.rs rename to crates/codex-api/src/observability/providers.rs index 2a88931b..2025accb 100644 --- a/src/observability/providers.rs +++ b/crates/codex-api/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/crates/codex-api/src/observability/stub.rs similarity index 97% rename from src/observability/stub.rs rename to crates/codex-api/src/observability/stub.rs index d02a96f9..aeb1f806 100644 --- a/src/observability/stub.rs +++ b/crates/codex-api/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/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/crates/codex-api/src/permissions.rs b/crates/codex-api/src/permissions.rs new file mode 100644 index 00000000..5226872e --- /dev/null +++ b/crates/codex-api/src/permissions.rs @@ -0,0 +1,8 @@ +//! Re-export of the cross-layer permission types. +//! +//! 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 codex_models::permissions::*; diff --git a/src/api/routes/komga/dto/book.rs b/crates/codex-api/src/routes/komga/dto/book.rs similarity index 98% rename from src/api/routes/komga/dto/book.rs rename to crates/codex-api/src/routes/komga/dto/book.rs index 7ec7ed03..8813c20f 100644 --- a/src/api/routes/komga/dto/book.rs +++ b/crates/codex-api/src/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/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 97% rename from src/api/routes/komga/dto/mod.rs rename to crates/codex-api/src/routes/komga/dto/mod.rs index 49f6cb05..0b9b4ff4 100644 --- a/src/api/routes/komga/dto/mod.rs +++ b/crates/codex-api/src/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/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 355a360e..b4c82a07 100644 --- a/src/api/routes/komga/handlers/books.rs +++ b/crates/codex-api/src/routes/komga/handlers/books.rs @@ -9,17 +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::db::repositories::{ - BookMetadataRepository, BookQueryOptions, BookQuerySort, BookRepository, BookSortField, - ReadProgressRepository, ReadStatusFilter, ReleaseDateFilter, ReleaseDateOperator, - SeriesMetadataRepository, -}; -use crate::require_permission; use axum::{ Json, body::Body, @@ -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, series_id: Uuid) -> Result 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/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 ed65863a..0ae96822 100644 --- a/src/api/routes/komga/handlers/manifest.rs +++ b/crates/codex-api/src/routes/komga/handlers/manifest.rs @@ -4,20 +4,20 @@ //! 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::db::repositories::{BookMetadataRepository, BookRepository, SeriesRepository}; -use crate::parsers::epub::EpubParser; -use crate::require_permission; use axum::{ body::Body, extract::{OriginalUri, Path, State}, 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; use std::sync::Arc; 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 dcd365ea..355130a3 100644 --- a/src/api/routes/komga/handlers/pages.rs +++ b/crates/codex-api/src/routes/komga/handlers/pages.rs @@ -5,13 +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::db::repositories::{BookRepository, PageRepository}; -use crate::require_permission; use axum::{ Json, body::Body, @@ -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/crates/codex-api/src/routes/komga/handlers/read_progress.rs similarity index 98% rename from src/api/routes/komga/handlers/read_progress.rs rename to crates/codex-api/src/routes/komga/handlers/read_progress.rs index 8b476840..c8aa6b8e 100644 --- a/src/api/routes/komga/handlers/read_progress.rs +++ b/crates/codex-api/src/routes/komga/handlers/read_progress.rs @@ -5,18 +5,18 @@ //! 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::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; @@ -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() { @@ -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::>(positions_json) + serde_json::from_str::>(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/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 a5d4bc63..5dfa2b81 100644 --- a/src/api/routes/komga/handlers/series.rs +++ b/crates/codex-api/src/routes/komga/handlers/series.rs @@ -10,18 +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::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, body::Body, @@ -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, - series: &crate::db::entities::series::Model, + series: &codex_db::entities::series::Model, user_id: Option, ) -> Result { // Get metadata 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 93739f9b..72d925d2 100644 --- a/src/api/routes/komga/handlers/stubs.rs +++ b/crates/codex-api/src/routes/komga/handlers/stubs.rs @@ -6,17 +6,17 @@ 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::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/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 eee54e50..905b6823 100644 --- a/src/api/routes/koreader/handlers/sync.rs +++ b/crates/codex-api/src/routes/koreader/handlers/sync.rs @@ -4,15 +4,15 @@ //! (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::db::entities::books; -use crate::db::repositories::{BookRepository, ReadProgressRepository}; -use crate::parsers::EpubPosition; +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; +use codex_db::repositories::{BookRepository, ReadProgressRepository}; +use codex_parsers::EpubPosition; use std::sync::Arc; /// GET /koreader/syncs/progress/{document} 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 a5d6484c..6dbaa143 100644 --- a/src/api/routes/mod.rs +++ b/crates/codex-api/src/routes/mod.rs @@ -4,12 +4,12 @@ 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::config::Config; +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; use std::sync::Arc; use tower_http::catch_panic::CatchPanicLayer; use tower_http::cors::{Any, CorsLayer}; @@ -177,7 +177,7 @@ pub fn create_router(state: Arc, 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 63383884..10e0071d 100644 --- a/src/api/routes/opds/handlers/catalog.rs +++ b/crates/codex-api/src/routes/opds/handlers/catalog.rs @@ -1,20 +1,20 @@ 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::db::repositories::{ - BookMetadataRepository, BookRepository, LibraryRepository, ReadProgressRepository, - SeriesMetadataRepository, SeriesRepository, SettingsRepository, -}; -use crate::require_permission; use axum::{ extract::{Path, Query, State}, http::{StatusCode, header}, 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/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 97% rename from src/api/routes/opds/handlers/pse.rs rename to crates/codex-api/src/routes/opds/handlers/pse.rs index e52016e6..fd400e92 100644 --- a/src/api/routes/opds/handlers/pse.rs +++ b/crates/codex-api/src/routes/opds/handlers/pse.rs @@ -1,18 +1,18 @@ 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::db::repositories::{BookMetadataRepository, BookRepository, SettingsRepository}; -use crate::require_permission; use axum::{ extract::{Path, State}, http::{HeaderMap, StatusCode, header}, 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/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 3640581e..77bab314 100644 --- a/src/api/routes/opds/handlers/search.rs +++ b/crates/codex-api/src/routes/opds/handlers/search.rs @@ -1,20 +1,20 @@ 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::db::repositories::{ - BookMetadataRepository, BookRepository, ReadProgressRepository, SeriesMetadataRepository, - SeriesRepository, SettingsRepository, -}; -use crate::require_permission; use axum::{ extract::{Query, State}, http::{StatusCode, header}, 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/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 b598eed1..35d71e86 100644 --- a/src/api/routes/opds2/handlers/catalog.rs +++ b/crates/codex-api/src/routes/opds2/handlers/catalog.rs @@ -2,23 +2,23 @@ //! //! 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::db::repositories::{ - BookMetadataRepository, BookRepository, LibraryRepository, ReadProgressRepository, - SeriesMetadataRepository, SeriesRepository, SettingsRepository, -}; -use crate::require_permission; use axum::{ Json, extract::{Path, Query, State}, 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/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 4179d1ad..85725b92 100644 --- a/src/api/routes/opds2/handlers/search.rs +++ b/crates/codex-api/src/routes/opds2/handlers/search.rs @@ -2,17 +2,17 @@ //! //! 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::db::repositories::{ +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/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 94% rename from src/api/routes/v1/dto/book.rs rename to crates/codex-api/src/routes/v1/dto/book.rs index 4d71b7a5..344bf8ea 100644 --- a/src/api/routes/v1/dto/book.rs +++ b/crates/codex-api/src/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,10 +6,9 @@ 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; +pub use codex_db::entities::book_metadata::BookType; // ============================================================================= // Book Type DTO (API representation) @@ -211,8 +209,8 @@ pub struct BookExternalIdDto { pub updated_at: DateTime, } -impl From for BookExternalIdDto { - fn from(model: crate::db::entities::book_external_ids::Model) -> Self { +impl From for BookExternalIdDto { + fn from(model: codex_db::entities::book_external_ids::Model) -> Self { Self { id: model.id, book_id: model.book_id, @@ -274,8 +272,8 @@ pub struct BookCoverDto { pub updated_at: DateTime, } -impl From for BookCoverDto { - fn from(model: crate::db::entities::book_covers::Model) -> Self { +impl From for BookCoverDto { + fn from(model: codex_db::entities::book_covers::Model) -> Self { Self { id: model.id, book_id: model.book_id, @@ -358,8 +356,8 @@ pub struct BookExternalLinkDto { pub updated_at: DateTime, } -impl From for BookExternalLinkDto { - fn from(model: crate::db::entities::book_external_links::Model) -> Self { +impl From for BookExternalLinkDto { + fn from(model: codex_db::entities::book_external_links::Model) -> Self { Self { id: model.id, book_id: model.book_id, @@ -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 `codex_models::sort` so db repositories can take +// typed sort params without depending on the api layer. +pub use codex_models::sort::{BookSortField, BookSortParam}; /// Book data transfer object #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] @@ -1741,9 +1628,9 @@ pub enum BookErrorTypeDto { Other, } -impl From for BookErrorTypeDto { - fn from(t: crate::db::entities::book_error::BookErrorType) -> Self { - use crate::db::entities::book_error::BookErrorType; +impl From 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, @@ -1757,9 +1644,9 @@ impl From for BookErrorTypeDto { } } -impl From for crate::db::entities::book_error::BookErrorType { +impl From 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, @@ -2594,8 +2481,8 @@ pub struct BookContextDto { } // Conversion from internal BookContext to DTO -impl From for BookContextDto { - fn from(ctx: crate::services::metadata::preprocessing::context::BookContext) -> Self { +impl From 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/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 96% rename from src/api/routes/v1/dto/cleanup.rs rename to crates/codex-api/src/routes/v1/dto/cleanup.rs index 1593a4b7..32eb5168 100644 --- a/src/api/routes/v1/dto/cleanup.rs +++ b/crates/codex-api/src/routes/v1/dto/cleanup.rs @@ -71,8 +71,8 @@ pub struct CleanupResultDto { pub errors: Vec, } -impl From for CleanupResultDto { - fn from(stats: crate::services::file_cleanup::CleanupStats) -> Self { +impl From 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/common.rs b/crates/codex-api/src/routes/v1/dto/common.rs similarity index 99% rename from src/api/routes/v1/dto/common.rs rename to crates/codex-api/src/routes/v1/dto/common.rs index 84b4463c..178a236d 100644 --- a/src/api/routes/v1/dto/common.rs +++ b/crates/codex-api/src/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/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 73% rename from src/api/routes/v1/dto/filter.rs rename to crates/codex-api/src/routes/v1/dto/filter.rs index 75ba1555..c87e4424 100644 --- a/src/api/routes/v1/dto/filter.rs +++ b/crates/codex-api/src/routes/v1/dto/filter.rs @@ -1,274 +1,17 @@ -use chrono::{DateTime, Utc}; +//! Filter DTOs. +//! +//! 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. + 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 codex_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/filter_preset.rs b/crates/codex-api/src/routes/v1/dto/filter_preset.rs similarity index 98% rename from src/api/routes/v1/dto/filter_preset.rs rename to crates/codex-api/src/routes/v1/dto/filter_preset.rs index bec13b80..e137be67 100644 --- a/src/api/routes/v1/dto/filter_preset.rs +++ b/crates/codex-api/src/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/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 99% rename from src/api/routes/v1/dto/library.rs rename to crates/codex-api/src/routes/v1/dto/library.rs index 8b099882..d05c68fb 100644 --- a/src/api/routes/v1/dto/library.rs +++ b/crates/codex-api/src/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/library_jobs.rs b/crates/codex-api/src/routes/v1/dto/library_jobs.rs similarity index 98% rename from src/api/routes/v1/dto/library_jobs.rs rename to crates/codex-api/src/routes/v1/dto/library_jobs.rs index f596be85..9e38f4e6 100644 --- a/src/api/routes/v1/dto/library_jobs.rs +++ b/crates/codex-api/src/routes/v1/dto/library_jobs.rs @@ -6,8 +6,8 @@ use std::collections::HashMap; use utoipa::ToSchema; use uuid::Uuid; -use crate::api::routes::v1::dto::patch::PatchValue; -use crate::services::library_jobs::{LibraryJobConfig, MetadataRefreshJobConfig, RefreshScope}; +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 94% rename from src/api/routes/v1/dto/pdf_cache.rs rename to crates/codex-api/src/routes/v1/dto/pdf_cache.rs index b9fdec96..7219661d 100644 --- a/src/api/routes/v1/dto/pdf_cache.rs +++ b/crates/codex-api/src/routes/v1/dto/pdf_cache.rs @@ -41,8 +41,8 @@ pub struct PdfPageCacheStatsDto { pub cache_enabled: bool, } -impl From for PdfPageCacheStatsDto { - fn from(stats: crate::services::CacheStats) -> Self { +impl From 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 for PdfHandleCacheEntryDto { - fn from(entry: crate::services::HandleCacheEntrySnapshot) -> Self { +impl From 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, } -impl From for PdfHandleCacheStatsDto { - fn from(snap: crate::services::HandleCacheSnapshot) -> Self { +impl From 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 for PdfCacheCleanupResultDto { - fn from(result: crate::services::CleanupResult) -> Self { +impl From 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/crates/codex-api/src/routes/v1/dto/plugin_storage.rs similarity index 92% rename from src/api/routes/v1/dto/plugin_storage.rs rename to crates/codex-api/src/routes/v1/dto/plugin_storage.rs index 19c5f67c..2b4bfda9 100644 --- a/src/api/routes/v1/dto/plugin_storage.rs +++ b/crates/codex-api/src/routes/v1/dto/plugin_storage.rs @@ -20,8 +20,8 @@ pub struct PluginStorageStatsDto { pub total_bytes: u64, } -impl From for PluginStorageStatsDto { - fn from(stats: crate::services::PluginStorageStats) -> Self { +impl From 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, } -impl From for PluginCleanupResultDto { - fn from(stats: crate::services::PluginCleanupStats) -> Self { +impl From 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/crates/codex-api/src/routes/v1/dto/plugins.rs similarity index 98% rename from src/api/routes/v1/dto/plugins.rs rename to crates/codex-api/src/routes/v1/dto/plugins.rs index 32a4c568..afa8e1b0 100644 --- a/src/api/routes/v1/dto/plugins.rs +++ b/crates/codex-api/src/routes/v1/dto/plugins.rs @@ -8,10 +8,10 @@ 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::{ +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, }; @@ -285,8 +285,8 @@ pub struct OAuthConfigDto { pub user_info_url: Option, } -impl From for OAuthConfigDto { - fn from(o: crate::services::plugin::protocol::OAuthConfig) -> Self { +impl From 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, } -impl From for PluginManifestDto { - fn from(m: crate::services::plugin::protocol::PluginManifest) -> Self { +impl From for PluginManifestDto { + fn from(m: codex_services::plugin::protocol::PluginManifest) -> Self { // Derive content types from capabilities let content_types: Vec = m .capabilities @@ -459,9 +459,9 @@ pub struct CredentialFieldDto { impl From 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 for PluginSearchResultDto { - fn from(r: crate::services::plugin::protocol::SearchResult) -> Self { +impl From 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 for PluginSearchResul } } -impl From for SearchResultPreviewDto { - fn from(p: crate::services::plugin::protocol::SearchResultPreview) -> Self { +impl From 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/read_progress.rs b/crates/codex-api/src/routes/v1/dto/read_progress.rs similarity index 98% rename from src/api/routes/v1/dto/read_progress.rs rename to crates/codex-api/src/routes/v1/dto/read_progress.rs index 921174ae..19af3240 100644 --- a/src/api/routes/v1/dto/read_progress.rs +++ b/crates/codex-api/src/routes/v1/dto/read_progress.rs @@ -67,8 +67,8 @@ pub struct ReadProgressResponse { pub completed_at: Option>, } -impl From for ReadProgressResponse { - fn from(model: crate::db::entities::read_progress::Model) -> Self { +impl From 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/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 98% rename from src/api/routes/v1/dto/release.rs rename to crates/codex-api/src/routes/v1/dto/release.rs index 1eb9bf8f..e17c392d 100644 --- a/src/api/routes/v1/dto/release.rs +++ b/crates/codex-api/src/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 @@ -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 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/scan.rs b/crates/codex-api/src/routes/v1/dto/scan.rs similarity index 94% rename from src/api/routes/v1/dto/scan.rs rename to crates/codex-api/src/routes/v1/dto/scan.rs index f7f8d776..d8034947 100644 --- a/src/api/routes/v1/dto/scan.rs +++ b/crates/codex-api/src/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)] @@ -121,10 +121,10 @@ impl ScanningConfigDto { /// If `cron_timezone` is set, validates it as a valid IANA timezone name. pub fn validated(self) -> Result { 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) } @@ -155,8 +155,8 @@ pub struct AnalysisResult { pub errors: Vec, } -impl From for AnalysisResult { - fn from(result: crate::scanner::AnalysisResult) -> Self { +impl From 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/dto/series.rs b/crates/codex-api/src/routes/v1/dto/series.rs similarity index 91% rename from src/api/routes/v1/dto/series.rs rename to crates/codex-api/src/routes/v1/dto/series.rs index cc4b6b9b..cff9c91a 100644 --- a/src/api/routes/v1/dto/series.rs +++ b/crates/codex-api/src/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 `codex_models::sort` so db repositories can take +// typed sort params without depending on the api layer. +pub use codex_models::sort::{SeriesSortField, SeriesSortParam, SortDirection}; /// Series data transfer object #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] @@ -963,8 +796,8 @@ pub struct SeriesExternalIdDto { pub updated_at: DateTime, } -impl From for SeriesExternalIdDto { - fn from(model: crate::db::entities::series_external_ids::Model) -> Self { +impl From for SeriesExternalIdDto { + fn from(model: codex_db::entities::series_external_ids::Model) -> Self { Self { id: model.id, series_id: model.series_id, @@ -2007,8 +1840,8 @@ pub struct SeriesContextDto { } // Conversion from internal SeriesContext to DTO -impl From for SeriesContextDto { - fn from(ctx: crate::services::metadata::preprocessing::context::SeriesContext) -> Self { +impl From 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/dto/series_export.rs b/crates/codex-api/src/routes/v1/dto/series_export.rs similarity index 97% rename from src/api/routes/v1/dto/series_export.rs rename to crates/codex-api/src/routes/v1/dto/series_export.rs index 793c8b92..91e45120 100644 --- a/src/api/routes/v1/dto/series_export.rs +++ b/crates/codex-api/src/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 = serde_json::from_value(m.library_ids.clone()).unwrap_or_default(); let fields: Vec = serde_json::from_value(m.fields.clone()).unwrap_or_default(); 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 93% rename from src/api/routes/v1/dto/sharing_tag.rs rename to crates/codex-api/src/routes/v1/dto/sharing_tag.rs index 10697469..0d5da9a7 100644 --- a/src/api/routes/v1/dto/sharing_tag.rs +++ b/crates/codex-api/src/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 for SharingTagSummaryDto { - fn from(model: crate::db::entities::sharing_tags::Model) -> Self { +impl From 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 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/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 99% rename from src/api/routes/v1/dto/tracking.rs rename to crates/codex-api/src/routes/v1/dto/tracking.rs index 94140a65..18022aae 100644 --- a/src/api/routes/v1/dto/tracking.rs +++ b/crates/codex-api/src/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.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 99% rename from src/api/routes/v1/dto/user_plugins.rs rename to crates/codex-api/src/routes/v1/dto/user_plugins.rs index db1de5c2..679cdc3e 100644 --- a/src/api/routes/v1/dto/user_plugins.rs +++ b/crates/codex-api/src/routes/v1/dto/user_plugins.rs @@ -245,8 +245,8 @@ pub struct UserPluginTaskDto { pub completed_at: Option>, } -impl From for UserPluginTaskDto { - fn from(task: crate::db::entities::tasks::Model) -> Self { +impl From 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/crates/codex-api/src/routes/v1/dto/user_preferences.rs similarity index 96% rename from src/api/routes/v1/dto/user_preferences.rs rename to crates/codex-api/src/routes/v1/dto/user_preferences.rs index f2e2e4f8..f5ee50a1 100644 --- a/src/api/routes/v1/dto/user_preferences.rs +++ b/crates/codex-api/src/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/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 5229335a..45d2052a 100644 --- a/src/api/routes/v1/handlers/api_keys.rs +++ b/crates/codex-api/src/routes/v1/handlers/api_keys.rs @@ -5,14 +5,11 @@ use super::super::dto::{ }, }; use super::paginated_response; -use crate::api::{ +use crate::{ error::ApiError, extractors::{AuthContext, AuthState}, permissions::{Permission, serialize_permissions}, }; -use crate::db::entities::api_keys; -use crate::db::repositories::ApiKeyRepository; -use crate::utils::password; use axum::{ Json, extract::{Path, Query, State}, @@ -20,6 +17,9 @@ 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; use std::collections::HashSet; 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 e55ed857..813833ca 100644 --- a/src/api/routes/v1/handlers/auth.rs +++ b/crates/codex-api/src/routes/v1/handlers/auth.rs @@ -3,17 +3,11 @@ 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 }; -use crate::db::{ - entities::users, - repositories::{EmailVerificationTokenRepository, SettingsRepository, UserRepository}, -}; -use crate::services::RefreshTokenError; -use crate::utils::password; use axum::{ Json, extract::State, @@ -21,6 +15,12 @@ use axum::{ response::{IntoResponse, Response}, }; use chrono::Utc; +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; use std::env; @@ -400,7 +400,7 @@ pub async fn register( Json(request): Json, ) -> Result { // Check if registration is enabled (from database settings) - use crate::db::repositories::SettingsRepository; + use codex_db::repositories::SettingsRepository; let registration_enabled = SettingsRepository::get_value::(&state.db, "auth.registration_enabled") .await 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 bdbd74fc..62084518 100644 --- a/src/api/routes/v1/handlers/books.rs +++ b/crates/codex-api/src/routes/v1/handlers/books.rs @@ -13,21 +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::db::repositories::{ - BookMetadataRepository, BookRepository, GenreRepository, LibraryRepository, PageRepository, - ReadProgressRepository, SeriesMetadataRepository, TagRepository, -}; -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 +26,15 @@ use axum::{ http::{StatusCode, header}, response::{IntoResponse, Response}, }; +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, +}; use serde::Deserialize; use std::collections::{HashMap, HashSet}; use std::sync::Arc; @@ -180,7 +180,7 @@ pub struct BookGetQuery { pub async fn books_to_dtos( db: &sea_orm::DatabaseConnection, user_id: Uuid, - books: Vec, + books: Vec, ) -> Result, ApiError> { if books.is_empty() { return Ok(Vec::new()); @@ -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 = + 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 @@ -316,7 +314,7 @@ pub async fn books_to_dtos( pub async fn books_to_full_dtos_batched( db: &sea_orm::DatabaseConnection, user_id: Uuid, - books: Vec, + books: Vec, ) -> Result, ApiError> { use chrono::Utc; @@ -858,8 +856,8 @@ pub async fn list_books_filtered( Query(pagination): Query, Json(request): Json, ) -> Result { - 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)?; @@ -883,7 +881,7 @@ pub async fn list_books_filtered( None }; - let fuzzy_enabled = crate::db::repositories::SettingsRepository::get_value::( + let fuzzy_enabled = codex_db::repositories::SettingsRepository::get_value::( &state.db, "search.fuzzy.enabled", ) @@ -2131,12 +2129,12 @@ pub async fn get_book_file( // Book Metadata Endpoints // ============================================================================ -use crate::api::routes::v1::dto::{ +use crate::routes::v1::dto::{ BookMetadataResponse, PatchBookMetadataRequest, ReplaceBookMetadataRequest, }; -use crate::db::entities::book_metadata; -use crate::events::{EntityChangeEvent, EntityEvent}; use chrono::Utc; +use codex_db::entities::book_metadata; +use codex_events::{EntityChangeEvent, EntityEvent}; use sea_orm::{ActiveModelTrait, Set}; /// Replace all book metadata (PUT) @@ -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, }; @@ -3403,8 +3401,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; @@ -3473,7 +3471,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 @@ -3524,7 +3522,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 +3550,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 +3729,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 +3983,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 +4297,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; +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/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 c976358d..976f7269 100644 --- a/src/api/routes/v1/handlers/bulk.rs +++ b/crates/codex-api/src/routes/v1/handlers/bulk.rs @@ -9,18 +9,18 @@ use super::super::dto::{ BulkGenerateSeriesThumbnailsRequest, BulkMetadataResetResponse, BulkRenumberSeriesRequest, BulkReprocessSeriesTitlesRequest, BulkSeriesRequest, BulkTaskResponse, MarkReadResponse, }; -use crate::api::{AppState, error::ApiError, extractors::AuthContext, permissions::Permission}; -use crate::db::repositories::{ +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::{ AlternateTitleRepository, BookRepository, ExternalLinkRepository, ExternalRatingRepository, GenreRepository, ReadProgressRepository, SeriesCoversRepository, SeriesExternalIdRepository, 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 codex_tasks::types::TaskType; 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/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 c6173c18..2b5723c1 100644 --- a/src/api/routes/v1/handlers/bulk_metadata.rs +++ b/crates/codex-api/src/routes/v1/handlers/bulk_metadata.rs @@ -4,20 +4,20 @@ //! 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::db::entities::{book_metadata, series_metadata}; -use crate::db::repositories::{ +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}; +use codex_db::repositories::{ BookMetadataRepository, BookRepository, GenreRepository, SeriesMetadataRepository, SeriesRepository, TagRepository, }; -use crate::events::{EntityChangeEvent, EntityEvent}; -use crate::require_permission; -use crate::utils::{ +use codex_events::{EntityChangeEvent, EntityEvent}; +use codex_utils::{ json_merge_patch, parse_custom_metadata, serialize_custom_metadata, validate_custom_metadata_size, }; -use axum::{Json, extract::State}; -use chrono::Utc; use sea_orm::{ActiveModelTrait, Set}; use std::sync::Arc; use uuid::Uuid; diff --git a/src/api/routes/v1/handlers/cleanup.rs b/crates/codex-api/src/routes/v1/handlers/cleanup.rs similarity index 98% rename from src/api/routes/v1/handlers/cleanup.rs rename to crates/codex-api/src/routes/v1/handlers/cleanup.rs index 9384fd3a..db67e4ad 100644 --- a/src/api/routes/v1/handlers/cleanup.rs +++ b/crates/codex-api/src/routes/v1/handlers/cleanup.rs @@ -12,15 +12,15 @@ 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::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}; +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/crates/codex-api/src/routes/v1/handlers/duplicates.rs similarity index 96% rename from src/api/routes/v1/handlers/duplicates.rs rename to crates/codex-api/src/routes/v1/handlers/duplicates.rs index 59b063e6..55a4fb69 100644 --- a/src/api/routes/v1/handlers/duplicates.rs +++ b/crates/codex-api/src/routes/v1/handlers/duplicates.rs @@ -16,12 +16,12 @@ use super::super::dto::{ ListSeriesDuplicatesResponse, SeriesDuplicateGroup, SeriesDuplicateMember, 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::{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, }; -use crate::tasks::types::TaskType; +use codex_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 { 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/events.rs b/crates/codex-api/src/routes/v1/handlers/events.rs similarity index 98% rename from src/api/routes/v1/handlers/events.rs rename to crates/codex-api/src/routes/v1/handlers/events.rs index 18b90db2..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}, @@ -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/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 09ae31db..f91466ca 100644 --- a/src/api/routes/v1/handlers/filter_presets.rs +++ b/crates/codex-api/src/routes/v1/handlers/filter_presets.rs @@ -12,9 +12,9 @@ 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::db::repositories::{ +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 { +pub async fn get_app_info(State(state): State>) -> Json { 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 96% rename from src/api/routes/v1/handlers/libraries.rs rename to crates/codex-api/src/routes/v1/handlers/libraries.rs index d8aafa23..739c5c38 100644 --- a/src/api/routes/v1/handlers/libraries.rs +++ b/crates/codex-api/src/routes/v1/handlers/libraries.rs @@ -6,22 +6,22 @@ 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::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::{ Json, extract::{Path, Query, State}, response::Response, }; 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; @@ -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(); @@ -304,12 +304,12 @@ 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(), }; - 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)))?; } @@ -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 }, @@ -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/crates/codex-api/src/routes/v1/handlers/library_jobs.rs similarity index 96% rename from src/api/routes/v1/handlers/library_jobs.rs rename to crates/codex-api/src/routes/v1/handlers/library_jobs.rs index a823e348..3241f0e7 100644 --- a/src/api/routes/v1/handlers/library_jobs.rs +++ b/crates/codex-api/src/routes/v1/handlers/library_jobs.rs @@ -9,21 +9,21 @@ 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::db::entities::library_jobs; -use crate::db::repositories::{ +use codex_db::entities::library_jobs; +use codex_db::repositories::{ CreateLibraryJobParams, LibraryJobRepository, LibraryRepository, SeriesRepository, }; -use crate::require_permission; -use crate::services::library_jobs::{ +use codex_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_services::metadata::{FieldGroup, RefreshPlanner, fields_for_group}; +use codex_tasks::types::TaskType; use super::super::dto::patch::PatchValue; use super::super::dto::{ @@ -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"))? { @@ -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, @@ -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/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 e926d67a..ba42f614 100644 --- a/src/api/routes/v1/handlers/metrics.rs +++ b/crates/codex-api/src/routes/v1/handlers/metrics.rs @@ -6,8 +6,8 @@ use super::super::dto::{ LibraryMetricsDto, MetricsDto, PluginMethodMetricsDto, PluginMetricsDto, PluginMetricsResponse, PluginMetricsSummaryDto, }; -use crate::api::{AppState, error::ApiError, extractors::AuthContext, permissions::Permission}; -use crate::db::repositories::MetricsRepository; +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 6a8fe08f..ed99401b 100644 --- a/src/api/routes/v1/handlers/observability.rs +++ b/crates/codex-api/src/routes/v1/handlers/observability.rs @@ -19,11 +19,11 @@ use axum::{ }; use tokio::sync::OnceCell; -use crate::api::{ +use crate::{ 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/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 9e43f310..57eaaea5 100644 --- a/src/api/routes/v1/handlers/oidc.rs +++ b/crates/codex-api/src/routes/v1/handlers/oidc.rs @@ -8,11 +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::db::{ - entities::users, - repositories::{OidcConnectionRepository, UserRepository}, -}; +use crate::{error::ApiError, extractors::AppState, permissions::UserRole}; 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/crates/codex-api/src/routes/v1/handlers/pages.rs similarity index 96% rename from src/api/routes/v1/handlers/pages.rs rename to crates/codex-api/src/routes/v1/handlers/pages.rs index b0251090..65d2dcc0 100644 --- a/src/api/routes/v1/handlers/pages.rs +++ b/crates/codex-api/src/routes/v1/handlers/pages.rs @@ -1,17 +1,17 @@ -use crate::api::{ +use crate::require_permission; +use crate::{ error::ApiError, extractors::{AuthState, FlexibleAuthContext}, permissions::Permission, }; -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_db::repositories::{BookCoversRepository, BookRepository, PageRepository}; +use codex_utils::{DeadlineResult, with_deadline}; use httpdate::fmt_http_date; use image::{ImageFormat, imageops::FilterType}; use std::io::Cursor; @@ -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 /// @@ -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> { 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)))? @@ -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, - book: &crate::db::entities::books::Model, + book: &codex_db::entities::books::Model, ) -> Result, ApiError> { let book_id = book.id; @@ -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/pdf_cache.rs b/crates/codex-api/src/routes/v1/handlers/pdf_cache.rs similarity index 98% rename from src/api/routes/v1/handlers/pdf_cache.rs rename to crates/codex-api/src/routes/v1/handlers/pdf_cache.rs index 91d470c5..3382d3a0 100644 --- a/src/api/routes/v1/handlers/pdf_cache.rs +++ b/crates/codex-api/src/routes/v1/handlers/pdf_cache.rs @@ -15,14 +15,14 @@ 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::db::repositories::TaskRepository; -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 { 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 041f4a52..80557826 100644 --- a/src/api/routes/v1/handlers/plugin_actions.rs +++ b/crates/codex-api/src/routes/v1/handlers/plugin_actions.rs @@ -19,30 +19,30 @@ 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::db::entities::plugins::PluginPermission; -use crate::db::repositories::{ +use crate::{AppState, error::ApiError, extractors::AuthContext, permissions::Permission}; +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 crate::services::metadata::preprocessing::{ +use codex_services::metadata::preprocessing::{ PreprocessingRule, SeriesContextBuilder, apply_rules, render_template, }; -use crate::services::metadata::{ +use codex_services::metadata::{ ApplyOptions, BookApplyOptions, BookMetadataApplier, MetadataApplier, }; -use crate::services::plugin::PluginManagerError; -use crate::services::plugin::protocol::{ +use codex_services::plugin::PluginManagerError; +use codex_services::plugin::protocol::{ BookMatchParams, BookSearchParams, MetadataContentType, MetadataGetParams, MetadataMatchParams, MetadataSearchParams, PluginScope, }; -use crate::tasks::types::TaskType; -use axum::{ - Json, - extract::{Path, Query, State}, -}; +use codex_tasks::types::TaskType; 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/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 98% rename from src/api/routes/v1/handlers/plugins.rs rename to crates/codex-api/src/routes/v1/handlers/plugins.rs index ede2ad73..2ee94457 100644 --- a/src/api/routes/v1/handlers/plugins.rs +++ b/crates/codex-api/src/routes/v1/handlers/plugins.rs @@ -10,18 +10,18 @@ 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::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; +use crate::{AppState, error::ApiError, extractors::AuthContext, permissions::Permission}; use axum::{ Json, 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 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; @@ -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/crates/codex-api/src/routes/v1/handlers/read_progress.rs similarity index 95% rename from src/api/routes/v1/handlers/read_progress.rs rename to crates/codex-api/src/routes/v1/handlers/read_progress.rs index 1ba179e6..25c7b218 100644 --- a/src/api/routes/v1/handlers/read_progress.rs +++ b/crates/codex-api/src/routes/v1/handlers/read_progress.rs @@ -1,14 +1,14 @@ 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 crate::{AppState, error::ApiError, extractors::AuthContext, permissions::Permission}; 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; @@ -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::>(spine_json) + serde_json::from_str::>(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::>(positions_json) + serde_json::from_str::>(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/recommendations.rs b/crates/codex-api/src/routes/v1/handlers/recommendations.rs similarity index 97% rename from src/api/routes/v1/handlers/recommendations.rs rename to crates/codex-api/src/routes/v1/handlers/recommendations.rs index 66aaedbc..3e273365 100644 --- a/src/api/routes/v1/handlers/recommendations.rs +++ b/crates/codex-api/src/routes/v1/handlers/recommendations.rs @@ -8,20 +8,20 @@ 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::db::repositories::{ - PluginsRepository, SeriesExternalIdRepository, TaskRepository, UserPluginDataRepository, - UserPluginsRepository, -}; -use crate::services::plugin::protocol::PluginManifest; -use crate::services::plugin::recommendations::RecommendationResponse; -use crate::tasks::types::TaskType; +use crate::extractors::auth::AuthContext; +use crate::{error::ApiError, extractors::AppState}; use axum::{ Json, extract::{Path, State}, }; use chrono::Utc; +use codex_db::repositories::{ + PluginsRepository, SeriesExternalIdRepository, TaskRepository, UserPluginDataRepository, + UserPluginsRepository, +}; +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; @@ -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 @@ -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; @@ -488,11 +488,11 @@ pub async fn dismiss_recommendation( #[cfg(test)] 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 crate::error::ApiError; + 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. @@ -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 { @@ -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/crates/codex-api/src/routes/v1/handlers/releases.rs similarity index 98% rename from src/api/routes/v1/handlers/releases.rs rename to crates/codex-api/src/routes/v1/handlers/releases.rs index 5cb3e18e..9702c25e 100644 --- a/src/api/routes/v1/handlers/releases.rs +++ b/crates/codex-api/src/routes/v1/handlers/releases.rs @@ -35,17 +35,17 @@ use super::super::dto::release::{ UpdateReleaseSourceRequest, }; use super::paginated_response; -use crate::api::{ +use crate::{ error::ApiError, 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, }; -use crate::events::{EntityChangeEvent, EntityEvent}; +use codex_events::{EntityChangeEvent, EntityEvent}; /// Hydrate ledger rows with series titles via a single batched lookup. /// @@ -55,7 +55,7 @@ use crate::events::{EntityChangeEvent, EntityEvent}; /// existing `SeriesRepository::get_by_ids` batch query. async fn hydrate_ledger_dtos( db: &sea_orm::DatabaseConnection, - rows: Vec, + rows: Vec, ) -> Result, ApiError> { let mut series_ids: Vec = rows.iter().map(|r| r.series_id).collect(); series_ids.sort_unstable(); @@ -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) => { @@ -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; @@ -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::( + manifest_json.clone(), + ) else { continue; }; if manifest.capabilities.release_source.is_none() { diff --git a/src/api/routes/v1/handlers/scan.rs b/crates/codex-api/src/routes/v1/handlers/scan.rs similarity index 97% rename from src/api/routes/v1/handlers/scan.rs rename to crates/codex-api/src/routes/v1/handlers/scan.rs index 8e853cb4..37d1daed 100644 --- a/src/api/routes/v1/handlers/scan.rs +++ b/crates/codex-api/src/routes/v1/handlers/scan.rs @@ -13,12 +13,10 @@ 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 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; /// 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() @@ -324,14 +322,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() => { @@ -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/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 b8866391..9e527900 100644 --- a/src/api/routes/v1/handlers/series.rs +++ b/crates/codex-api/src/routes/v1/handlers/series.rs @@ -21,27 +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::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::events::{EntityChangeEvent, EntityEvent, EntityType}; -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 +35,19 @@ 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_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, +}; use httpdate::fmt_http_date; use sea_orm::DatabaseConnection; use serde::Deserialize; @@ -64,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)] @@ -1091,7 +1089,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::( + let fuzzy_enabled = codex_db::repositories::SettingsRepository::get_value::( &state.db, "search.fuzzy.enabled", ) @@ -1184,8 +1182,8 @@ pub async fn list_series_filtered( Query(pagination): Query, Json(request): Json, ) -> Result { - use crate::api::routes::v1::dto::series::{SeriesSortField, SortDirection}; - use crate::services::FilterService; + use crate::routes::v1::dto::series::{SeriesSortField, SortDirection}; + use codex_services::FilterService; use std::collections::HashSet; require_permission!(auth, Permission::SeriesRead)?; @@ -1194,7 +1192,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::( + let fuzzy_enabled = codex_db::repositories::SettingsRepository::get_value::( &state.db, "search.fuzzy.enabled", ) @@ -1417,7 +1415,7 @@ pub async fn list_series_alphabetical_groups( auth: AuthContext, Json(request): Json, ) -> Result>, ApiError> { - use crate::services::FilterService; + use codex_services::FilterService; use std::collections::HashMap; require_permission!(auth, Permission::SeriesRead)?; @@ -1669,7 +1667,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]; @@ -1983,8 +1981,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; + use codex_tasks::types::TaskType; let task_type = TaskType::GenerateSeriesThumbnail { series_id, @@ -2996,10 +2994,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 +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::db::repositories::TaskRepository; - 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/crates/codex-api/src/routes/v1/handlers/series_exports.rs similarity index 97% rename from src/api/routes/v1/handlers/series_exports.rs rename to crates/codex-api/src/routes/v1/handlers/series_exports.rs index 4d4bf81f..8906da3a 100644 --- a/src/api/routes/v1/handlers/series_exports.rs +++ b/crates/codex-api/src/routes/v1/handlers/series_exports.rs @@ -10,12 +10,12 @@ 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::db::repositories::{SeriesExportRepository, TaskRepository}; -use crate::services::book_export_collector::BookExportField; -use crate::services::series_export_collector::ExportField; -use crate::tasks::types::TaskType; +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; +use codex_tasks::types::TaskType; use super::super::dto::series_export::{ CreateSeriesExportRequest, ExportFieldCatalogResponse, ExportFieldDto, ExportPresetsDto, 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 55152050..b16529c6 100644 --- a/src/api/routes/v1/handlers/settings.rs +++ b/crates/codex-api/src/routes/v1/handlers/settings.rs @@ -2,17 +2,17 @@ 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::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; @@ -148,7 +148,7 @@ pub async fn get_setting( pub async fn update_setting( State(state): State>, auth: AuthContext, - client_info: crate::api::extractors::ClientInfo, + client_info: crate::extractors::ClientInfo, Path(setting_key): Path, Json(request): Json, ) -> Result, ApiError> { @@ -228,7 +228,7 @@ pub async fn update_setting( pub async fn bulk_update_settings( State(state): State>, auth: AuthContext, - client_info: crate::api::extractors::ClientInfo, + client_info: crate::extractors::ClientInfo, Json(request): Json, ) -> Result>, ApiError> { require_permission!(auth, Permission::SystemAdmin)?; @@ -305,7 +305,7 @@ pub async fn bulk_update_settings( pub async fn reset_setting( State(state): State>, auth: AuthContext, - client_info: crate::api::extractors::ClientInfo, + client_info: crate::extractors::ClientInfo, Path(setting_key): Path, ) -> Result, 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 98% rename from src/api/routes/v1/handlers/setup.rs rename to crates/codex-api/src/routes/v1/handlers/setup.rs index ccc02ce6..9fa6f103 100644 --- a/src/api/routes/v1/handlers/setup.rs +++ b/crates/codex-api/src/routes/v1/handlers/setup.rs @@ -3,17 +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::db::{ - entities::users, - repositories::{SettingsRepository, UserRepository}, -}; -use crate::require_permission; -use crate::utils::password; use axum::{ Json, extract::State, @@ -21,6 +16,11 @@ 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; @@ -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(), @@ -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/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 79ae0ba5..05477069 100644 --- a/src/api/routes/v1/handlers/sharing_tags.rs +++ b/crates/codex-api/src/routes/v1/handlers/sharing_tags.rs @@ -12,18 +12,18 @@ use super::super::dto::{ }, }; use super::paginated_response; -use crate::api::{ +use crate::{ error::ApiError, 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/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 72506c79..cee3d7b2 100644 --- a/src/api/routes/v1/handlers/task_metrics.rs +++ b/crates/codex-api/src/routes/v1/handlers/task_metrics.rs @@ -7,8 +7,8 @@ use super::super::dto::{ TaskMetricsHistoryQuery, TaskMetricsHistoryResponse, TaskMetricsResponse, TaskMetricsSummaryDto, TaskTypeMetricsDto, }; -use crate::api::{AppState, error::ApiError, extractors::AuthContext, permissions::Permission}; -use crate::db::repositories::TaskRepository; +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 97% rename from src/api/routes/v1/handlers/task_queue.rs rename to crates/codex-api/src/routes/v1/handlers/task_queue.rs index f630d19d..d2b7ba6b 100644 --- a/src/api/routes/v1/handlers/task_queue.rs +++ b/crates/codex-api/src/routes/v1/handlers/task_queue.rs @@ -8,18 +8,18 @@ use std::sync::Arc; use utoipa::ToSchema; use uuid::Uuid; -use crate::api::{error::ApiError, extractors::AuthContext, permissions::Permission}; -use crate::db::repositories::{ +use crate::require_permission; +use crate::{error::ApiError, extractors::AuthContext, permissions::Permission}; +use codex_db::repositories::{ LibraryRepository, SeriesMetadataRepository, SeriesRepository, TaskRepository, }; -use crate::require_permission; -use crate::tasks::types::{TaskStats, TaskType}; +use codex_tasks::types::{TaskStats, TaskType}; use super::super::dto::series::{ EnqueueReprocessTitleRequest, EnqueueReprocessTitleResponse, ReprocessSeriesTitlesRequest, ReprocessTitleRequest, }; -use crate::api::AppState; +use crate::AppState; // DTOs @@ -143,8 +143,8 @@ pub struct TaskResponse { pub library_name: Option, } -impl From for TaskResponse { - fn from(task: crate::db::entities::tasks::Model) -> Self { +impl From 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 for TaskResponse { } } -impl From for TaskResponse { - fn from(enriched: crate::db::repositories::task::TaskWithTargets) -> Self { +impl From 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, ) -> Result, 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, ) -> Result, 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, ) -> Result, 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)))? @@ -1032,7 +1032,7 @@ pub async fn reprocess_series_title( Path(series_id): Path, Json(request): Json, ) -> Result, 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, Json(request): Json, ) -> Result, 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/crates/codex-api/src/routes/v1/handlers/tracking.rs similarity index 98% rename from src/api/routes/v1/handlers/tracking.rs rename to crates/codex-api/src/routes/v1/handlers/tracking.rs index d7ad2be2..b50148e6 100644 --- a/src/api/routes/v1/handlers/tracking.rs +++ b/crates/codex-api/src/routes/v1/handlers/tracking.rs @@ -20,18 +20,18 @@ 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::db::entities::series_aliases::alias_source; -use crate::db::repositories::{ +use codex_db::entities::series_aliases::alias_source; +use codex_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}; +use codex_services::release::seed::seed_tracking_for_series; // ============================================================================= // Tracking config handlers diff --git a/src/api/routes/v1/handlers/user_plugins.rs b/crates/codex-api/src/routes/v1/handlers/user_plugins.rs similarity index 97% rename from src/api/routes/v1/handlers/user_plugins.rs rename to crates/codex-api/src/routes/v1/handlers/user_plugins.rs index c9e9699d..98afb950 100644 --- a/src/api/routes/v1/handlers/user_plugins.rs +++ b/crates/codex-api/src/routes/v1/handlers/user_plugins.rs @@ -11,27 +11,27 @@ 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::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; -use crate::tasks::types::TaskType; +use crate::extractors::auth::AuthContext; +use crate::{error::ApiError, extractors::AppState}; use axum::{ Json, extract::{Path, Query, State}, http::HeaderMap, }; +use codex_db::repositories::{ + PluginsRepository, TaskRepository, UserPluginDataRepository, UserPluginsRepository, +}; +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; /// 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 { +fn parse_manifest(plugin: &codex_db::entities::plugins::Model) -> Option { plugin .manifest .as_ref() @@ -40,7 +40,7 @@ fn parse_manifest(plugin: &crate::db::entities::plugins::Model) -> Option Option { 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 { +fn get_oauth_client_id(plugin: &codex_db::entities::plugins::Model) -> Option { // 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 Option { +fn get_oauth_client_secret(plugin: &codex_db::entities::plugins::Model) -> Option { 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>, ) -> UserPluginDto { let manifest = parse_manifest(plugin); diff --git a/src/api/routes/v1/handlers/user_preferences.rs b/crates/codex-api/src/routes/v1/handlers/user_preferences.rs similarity index 98% rename from src/api/routes/v1/handlers/user_preferences.rs rename to crates/codex-api/src/routes/v1/handlers/user_preferences.rs index 62b3fd29..43d3a52e 100644 --- a/src/api/routes/v1/handlers/user_preferences.rs +++ b/crates/codex-api/src/routes/v1/handlers/user_preferences.rs @@ -4,12 +4,12 @@ use super::super::dto::{ BulkSetPreferencesRequest, DeletePreferenceResponse, SetPreferenceRequest, SetPreferencesResponse, UserPreferenceDto, UserPreferencesResponse, }; -use crate::api::{AppState, error::ApiError, extractors::AuthContext}; -use crate::db::repositories::UserPreferencesRepository; +use crate::{AppState, error::ApiError, extractors::AuthContext}; 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/crates/codex-api/src/routes/v1/handlers/users.rs similarity index 98% rename from src/api/routes/v1/handlers/users.rs rename to crates/codex-api/src/routes/v1/handlers/users.rs index 1ffb1fbc..c93e1662 100644 --- a/src/api/routes/v1/handlers/users.rs +++ b/crates/codex-api/src/routes/v1/handlers/users.rs @@ -3,21 +3,21 @@ 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::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_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/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 92% rename from src/web.rs rename to crates/codex-api/src/web.rs index 2af92b73..bf523228 100644 --- a/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 /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) 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 96% rename from src/commands/common.rs rename to crates/codex-cli-common/src/lib.rs index 6c4d38de..215f5542 100644 --- a/src/commands/common.rs +++ b/crates/codex-cli-common/src/lib.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_api::observability::ObservabilityHandle; +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}; @@ -168,12 +168,12 @@ pub fn init_tracing(config: &Config) -> anyhow::Result { // 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/Subscriber bounds because each `.with(...)` changes S, so the @@ -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 @@ -467,14 +467,14 @@ pub fn spawn_workers( worker_count: u32, event_broadcaster: Arc, settings_service: Arc, - thumbnail_service: Arc, + thumbnail_service: Arc, task_metrics_service: Option>, - files_config: crate::config::FilesConfig, - pdf_page_cache: Option>, - pdf_handle_cache: Option>, - plugin_manager: Option>, - oauth_state_manager: Option>, - export_storage: Arc, + files_config: codex_config::FilesConfig, + pdf_page_cache: Option>, + pdf_handle_cache: Option>, + plugin_manager: Option>, + oauth_state_manager: Option>, + export_storage: Arc, ) -> ( Vec>, Vec>, @@ -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 codex_db::test_helpers::create_test_db; + use codex_services::SettingsService; 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/crates/codex-config/Cargo.toml b/crates/codex-config/Cargo.toml new file mode 100644 index 00000000..0abc81d2 --- /dev/null +++ b/crates/codex-config/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "codex-config" +version.workspace = true +edition.workspace = true +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-db/Cargo.toml b/crates/codex-db/Cargo.toml new file mode 100644 index 00000000..956bf443 --- /dev/null +++ b/crates/codex-db/Cargo.toml @@ -0,0 +1,61 @@ +[package] +name = "codex-db" +version.workspace = true +edition.workspace = true +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 98% rename from src/db/connection.rs rename to crates/codex-db/src/connection.rs index b614163d..19ec22a2 100644 --- a/src/db/connection.rs +++ b/crates/codex-db/src/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 crate::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/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 99% rename from src/db/entities/plugins.rs rename to crates/codex-db/src/entities/plugins.rs index 6eeda350..5420098e 100644 --- a/src/db/entities/plugins.rs +++ b/crates/codex-db/src/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 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::services::plugin::protocol::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 { + 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/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 98% rename from src/db/entities/users.rs rename to crates/codex-db/src/entities/users.rs index db101116..ec2b47e7 100644 --- a/src/db/entities/users.rs +++ b/crates/codex-db/src/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 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/crates/codex-db/src/lib.rs similarity index 55% rename from src/db/mod.rs rename to crates/codex-db/src/lib.rs index 55b228e9..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 @@ -10,6 +14,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/alternate_title.rs b/crates/codex-db/src/repositories/alternate_title.rs similarity index 97% rename from src/db/repositories/alternate_title.rs rename to crates/codex-db/src/repositories/alternate_title.rs index 33a05372..170c0999 100644 --- a/src/db/repositories/alternate_title.rs +++ b/crates/codex-db/src/repositories/alternate_title.rs @@ -10,10 +10,10 @@ 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 crate::events::{EntityChangeEvent, EntityEvent, EventBroadcaster}; +use codex_events::{EntityChangeEvent, EntityEvent, EventBroadcaster}; /// Repository for series alternate title operations pub struct AlternateTitleRepository; @@ -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 df0e01f7..e1a49923 100644 --- a/src/db/repositories/book.rs +++ b/crates/codex-db/src/repositories/book.rs @@ -14,11 +14,11 @@ use sea_orm::{ use std::sync::Arc; 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 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; /// Options for querying books with filtering, sorting, and pagination #[derive(Debug, Clone, Default)] @@ -151,7 +151,7 @@ impl BookRepository { db: &DatabaseConnection, options: BookQueryOptions<'_>, ) -> Result<(Vec, 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> { - 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> { - 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 @@ -912,15 +911,14 @@ impl BookRepository { pub async fn list_by_ids_sorted( db: &DatabaseConnection, ids: &[Uuid], - sort: &crate::api::routes::v1::dto::book::BookSortParam, + sort: &codex_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::entities::{book_metadata, read_progress, series, series_metadata}; + use codex_models::sort::{BookSortField, SortDirection}; use sea_orm::{Condition, JoinType}; if ids.is_empty() { @@ -1106,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 @@ -1135,7 +1133,7 @@ impl BookRepository { page: u64, page_size: u64, ) -> Result<(Vec, 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) @@ -1158,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()) @@ -1194,14 +1192,13 @@ impl BookRepository { pub async fn list_by_library_sorted( db: &DatabaseConnection, library_id: Uuid, - sort: &crate::api::routes::v1::dto::book::BookSortParam, + sort: &codex_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::entities::{book_metadata, series, series_metadata}; + use codex_models::sort::{BookSortField, SortDirection}; use sea_orm::JoinType; // Build base query @@ -1332,7 +1329,7 @@ impl BookRepository { page: u64, page_size: u64, ) -> Result<(Vec, u64)> { - use crate::db::entities::series; + use crate::entities::series; use sea_orm::JoinType; let mut query = Books::find(); @@ -1376,7 +1373,7 @@ impl BookRepository { library_id: Option, limit: u64, ) -> Result> { - use crate::db::entities::{read_progress, series}; + use crate::entities::{read_progress, series}; use sea_orm::JoinType; let mut query = Books::find() @@ -1408,7 +1405,7 @@ impl BookRepository { page: u64, page_size: u64, ) -> Result<(Vec, u64)> { - use crate::db::entities::{read_progress, series}; + use crate::entities::{read_progress, series}; use sea_orm::JoinType; let mut query = Books::find() @@ -1474,7 +1471,7 @@ impl BookRepository { page: u64, page_size: u64, ) -> Result<(Vec, 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 @@ -1532,7 +1529,7 @@ impl BookRepository { page: u64, page_size: u64, ) -> Result<(Vec, 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, @@ -1616,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()) @@ -1685,7 +1682,7 @@ impl BookRepository { include_deleted: bool, pagination: Option<(u64, u64)>, ) -> Result<(Vec, u64)> { - use crate::db::entities::book_metadata; + use crate::entities::book_metadata; use sea_orm::JoinType; // Short-circuit if candidate_ids is explicitly empty @@ -1850,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?; } @@ -1892,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(()) @@ -1902,7 +1899,7 @@ impl BookRepository { pub async fn count_by_library(db: &DatabaseConnection, library_id: Uuid) -> Result { // 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 = series_list.iter().map(|s| s.id).collect(); if series_ids.is_empty() { @@ -1926,11 +1923,11 @@ impl BookRepository { pub async fn purge_deleted_in_library( db: &DatabaseConnection, library_id: Uuid, - event_broadcaster: Option<&Arc>, + event_broadcaster: Option<&Arc>, ) -> Result { // 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 = series_list.iter().map(|s| s.id).collect(); if series_ids.is_empty() { @@ -1957,7 +1954,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 { @@ -1981,7 +1978,7 @@ impl BookRepository { } // Check if we should purge empty series - let purge_empty_series = crate::db::repositories::SettingsRepository::get_value::( + let purge_empty_series = crate::repositories::SettingsRepository::get_value::( db, "purge.purge_empty_series", ) @@ -1992,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, @@ -2008,10 +2005,10 @@ impl BookRepository { pub async fn purge_deleted_in_series( db: &DatabaseConnection, series_id: Uuid, - event_broadcaster: Option<&Arc>, + event_broadcaster: Option<&Arc>, ) -> Result { // 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")?; @@ -2033,7 +2030,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 { @@ -2057,7 +2054,7 @@ impl BookRepository { } // Check if we should purge empty series - let purge_empty_series = crate::db::repositories::SettingsRepository::get_value::( + let purge_empty_series = crate::repositories::SettingsRepository::get_value::( db, "purge.purge_empty_series", ) @@ -2067,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, @@ -2086,7 +2083,7 @@ impl BookRepository { ) -> Result> { // 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 = series_list.iter().map(|s| s.id).collect(); if series_ids.is_empty() { @@ -2123,7 +2120,7 @@ impl BookRepository { series_id: Uuid, user_id: Uuid, ) -> Result { - use crate::db::entities::read_progress; + use crate::entities::read_progress; use sea_orm::JoinType; // Count all non-deleted books in the series @@ -2157,7 +2154,7 @@ impl BookRepository { series_ids: &[Uuid], user_id: Uuid, ) -> Result> { - 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() { @@ -2293,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) @@ -2329,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) @@ -2378,7 +2375,7 @@ impl BookRepository { /// Get all errors for a book pub async fn get_errors(db: &DatabaseConnection, book_id: Uuid) -> Result { - 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) @@ -2399,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()) @@ -2456,7 +2453,7 @@ impl BookRepository { db: &DatabaseConnection, library_id: Option, ) -> Result> { - 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()) @@ -2670,7 +2667,7 @@ impl BookRepository { cursor: Option<(&str, Uuid)>, page_size: u64, ) -> Result> { - use crate::db::entities::book_metadata; + use crate::entities::book_metadata; use sea_orm::JoinType; let mut query = Books::find() @@ -2770,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> { - use crate::db::entities::book_metadata; + use crate::entities::book_metadata; let result: Option = book_metadata::Entity::find() .filter(book_metadata::Column::BookId.eq(book_id)) @@ -2788,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( @@ -3379,9 +3376,9 @@ mod tests { // Create user - use crate::db::entities::users; - use crate::db::repositories::{ReadProgressRepository, UserRepository}; - use crate::utils::password; + use crate::entities::users; + use crate::repositories::{ReadProgressRepository, UserRepository}; + use codex_utils::password; let password_hash = password::hash_password("test123").unwrap(); let user = users::Model { 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> { - use crate::db::entities::series_genres::Entity as SeriesGenres; + use crate::entities::series_genres::Entity as SeriesGenres; let genre_ids: Vec = SeriesGenres::find() .filter(series_genres::Column::SeriesId.eq(series_id)) @@ -111,7 +111,7 @@ impl GenreRepository { series_id: Uuid, genre_names: Vec, ) -> Result> { - 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 { - 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 { - 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> { - 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> { - 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> { - 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> { - 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> { - use crate::db::entities::series_genres::Entity as SeriesGenres; + use crate::entities::series_genres::Entity as SeriesGenres; let series_ids: Vec = SeriesGenres::find() .all(db) @@ -386,7 +386,7 @@ impl GenreRepository { db: &DatabaseConnection, series_ids: &[Uuid], ) -> Result>> { - 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> { - 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 97% rename from src/db/repositories/library.rs rename to crates/codex-db/src/repositories/library.rs index aaac7a9a..48d078a4 100644 --- a/src/db/repositories/library.rs +++ b/crates/codex-db/src/repositories/library.rs @@ -11,9 +11,9 @@ 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 crate::entities::{libraries, prelude::*}; +use crate::trace::db_system_str; +use codex_models::{BookStrategy, NumberStrategy, SeriesStrategy}; /// Parameters for creating a new library #[derive(Debug, Clone)] @@ -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 { let params = CreateLibraryParams::new(name, path); Self::create_with_params(db, params).await @@ -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 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 { - use crate::services::metadata::preprocessing::parse_auto_match_conditions; + ) -> Option { + use codex_models::preprocessing::parse_auto_match_conditions; match parse_auto_match_conditions(library.auto_match_conditions.as_deref()) { Ok(conditions) => conditions, @@ -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; @@ -966,7 +966,7 @@ mod tests { #[tokio::test] async fn test_get_auto_match_conditions_valid() { - use crate::services::metadata::preprocessing::{ConditionMode, ConditionOperator}; + use codex_models::preprocessing::{ConditionMode, ConditionOperator}; 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 61789849..dbfcd740 100644 --- a/src/db/repositories/metadata.rs +++ b/crates/codex-db/src/repositories/metadata.rs @@ -9,8 +9,8 @@ use chrono::Utc; use sea_orm::{ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, Set}; use uuid::Uuid; -use crate::db::entities::{book_metadata, prelude::*}; -use crate::utils::normalize_for_search; +use crate::entities::{book_metadata, prelude::*}; +use codex_utils::normalize_for_search; /// Repository for BookMetadata operations pub struct BookMetadataRepository; @@ -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 { - 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> { - 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 98% rename from src/db/repositories/plugin_failures.rs rename to crates/codex-db/src/repositories/plugin_failures.rs index 1b3c8b3c..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,10 +290,10 @@ 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::services::plugin::protocol::PluginScope; + 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 98% rename from src/db/repositories/plugins.rs rename to crates/codex-db/src/repositories/plugins.rs index 867a7c6d..e7247561 100644 --- a/src/db/repositories/plugins.rs +++ b/crates/codex-db/src/repositories/plugins.rs @@ -14,12 +14,12 @@ #![allow(dead_code)] -use crate::db::entities::plugins::{self, Entity as Plugins, PluginPermission}; -use crate::observability::repo::db_system_str; -use crate::services::CredentialEncryption; -use crate::services::plugin::protocol::{PluginManifest, PluginScope}; +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}; +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 { - use crate::services::metadata::preprocessing::parse_preprocessing_rules; + ) -> Vec { + 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 { - use crate::services::metadata::preprocessing::parse_auto_match_conditions; + ) -> Option { + use codex_models::preprocessing::parse_auto_match_conditions; match parse_auto_match_conditions(plugin.auto_match_conditions.as_deref()) { Ok(conditions) => conditions, @@ -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 98% rename from src/db/repositories/read_progress.rs rename to crates/codex-db/src/repositories/read_progress.rs index eda1d372..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,13 +319,13 @@ 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::models::ScanningStrategy; - use crate::utils::password; + use crate::test_helpers::setup_test_db; + 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/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 92d3620b..a19e1262 100644 --- a/src/db/repositories/release_ledger.rs +++ b/crates/codex-db/src/repositories/release_ledger.rs @@ -15,10 +15,10 @@ 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 crate::services::release::candidate::{NumericSpan, normalize_spans, primary_value}; +use codex_models::release::{NumericSpan, normalize_spans, primary_value}; /// New-row payload. Keys plus payload fields. /// @@ -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::().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::().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 4719dde5..bee1e746 100644 --- a/src/db/repositories/release_sources.rs +++ b/crates/codex-db/src/repositories/release_sources.rs @@ -16,11 +16,11 @@ 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::utils::cron::validate_cron_expression; +use crate::repositories::plugins::PluginsRepository; +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 @@ -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 98% rename from src/db/repositories/series.rs rename to crates/codex-db/src/repositories/series.rs index d3d75531..1ff30b5e 100644 --- a/src/db/repositories/series.rs +++ b/crates/codex-db/src/repositories/series.rs @@ -13,14 +13,14 @@ use sea_orm::{ }; use uuid::Uuid; -use crate::api::routes::v1::dto::series::{SeriesSortField, SeriesSortParam, SortDirection}; -use crate::db::entities::{ +use crate::entities::{ book_metadata, books, prelude::*, read_progress, series, series_external_ratings, series_metadata, user_series_ratings, }; -use crate::events::{EntityChangeEvent, EntityEvent, EventBroadcaster}; -use crate::observability::repo::db_system_str; -use crate::utils::normalize_for_search; +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; use std::sync::Arc; /// Options for querying series with filtering, sorting, and pagination @@ -1641,7 +1641,7 @@ impl SeriesRepository { user_id: Uuid, library_id: Option, ) -> Result> { - 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 @@ -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 codex_models::release::OwnedReleaseKeys; #[derive(Debug, FromQueryResult)] struct KeyRow { @@ -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) @@ -2235,9 +2235,9 @@ impl SeriesRepository { pub async fn purge_empty_series_in_library( db: &DatabaseConnection, library_id: Uuid, - event_broadcaster: Option<&Arc>, + event_broadcaster: Option<&Arc>, ) -> Result { - 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) @@ -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,9 +2299,9 @@ impl SeriesRepository { pub async fn purge_if_empty( db: &DatabaseConnection, series_id: Uuid, - event_broadcaster: Option<&Arc>, + event_broadcaster: Option<&Arc>, ) -> Result { - 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) @@ -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 { @@ -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,9 +2999,9 @@ mod tests { // Create user - use crate::db::entities::users; - use crate::db::repositories::{ReadProgressRepository, UserRepository}; - use crate::utils::password; + use crate::entities::users; + use crate::repositories::{ReadProgressRepository, UserRepository}; + use codex_utils::password; let password_hash = password::hash_password("test123").unwrap(); let user = users::Model { @@ -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, chapter: Option, ) -> 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 9e8cf91d..b9fb8065 100644 --- a/src/db/repositories/series_metadata.rs +++ b/crates/codex-db/src/repositories/series_metadata.rs @@ -10,9 +10,9 @@ 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::events::{EntityChangeEvent, EntityEvent, EventBroadcaster}; -use crate::utils::normalize_for_search; +use crate::entities::{series_metadata, series_metadata::Entity as SeriesMetadata}; +use codex_events::{EntityChangeEvent, EntityEvent, EventBroadcaster}; +use codex_utils::normalize_for_search; /// Repository for series metadata operations pub struct SeriesMetadataRepository; @@ -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 { - 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 { - 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> { - use crate::db::entities::series_sharing_tags::Entity as SeriesSharingTags; + use crate::entities::series_sharing_tags::Entity as SeriesSharingTags; let tag_ids: Vec = SeriesSharingTags::find() .filter(series_sharing_tags::Column::SeriesId.eq(series_id)) @@ -171,7 +171,7 @@ impl SharingTagRepository { series_id: Uuid, tag_ids: Vec, ) -> Result> { - 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 { - 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 { - 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> { - use crate::db::entities::series_sharing_tags::Entity as SeriesSharingTags; + use crate::entities::series_sharing_tags::Entity as SeriesSharingTags; let series_ids: Vec = SeriesSharingTags::find() .filter(series_sharing_tags::Column::SharingTagId.eq(tag_id)) @@ -269,7 +269,7 @@ impl SharingTagRepository { db: &DatabaseConnection, tag_name: &str, ) -> Result> { - 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> { - 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> { - 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> { - 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> { - 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> { - 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> { - use crate::db::entities::user_sharing_tags::Entity as UserSharingTags; + use crate::entities::user_sharing_tags::Entity as UserSharingTags; let tag_ids: Vec = UserSharingTags::find() .filter(user_sharing_tags::Column::UserId.eq(user_id)) @@ -464,7 +464,7 @@ impl SharingTagRepository { db: &DatabaseConnection, user_id: Uuid, ) -> Result> { - use crate::db::entities::user_sharing_tags::Entity as UserSharingTags; + use crate::entities::user_sharing_tags::Entity as UserSharingTags; let tag_ids: Vec = UserSharingTags::find() .filter(user_sharing_tags::Column::UserId.eq(user_id)) @@ -485,7 +485,7 @@ impl SharingTagRepository { tag_id: Uuid, access_mode: AccessMode, ) -> Result { - 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 { - 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 { - 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> { - use crate::db::entities::user_sharing_tags::Entity as UserSharingTags; + use crate::entities::user_sharing_tags::Entity as UserSharingTags; let user_ids: Vec = UserSharingTags::find() .filter(user_sharing_tags::Column::SharingTagId.eq(tag_id)) @@ -617,7 +617,7 @@ impl SharingTagRepository { db: &DatabaseConnection, user_id: Uuid, ) -> Result>> { - 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> { - 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> { - 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 = 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> { - use crate::db::entities::series_tags::Entity as SeriesTags; + use crate::entities::series_tags::Entity as SeriesTags; let tag_ids: Vec = SeriesTags::find() .filter(series_tags::Column::SeriesId.eq(series_id)) @@ -111,7 +111,7 @@ impl TagRepository { series_id: Uuid, tag_names: Vec, ) -> Result> { - 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 { - 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 { - 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> { - 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> { - 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> { - 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> { - 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> { - use crate::db::entities::series_tags::Entity as SeriesTags; + use crate::entities::series_tags::Entity as SeriesTags; let series_ids: Vec = SeriesTags::find() .all(db) @@ -379,7 +379,7 @@ impl TagRepository { db: &DatabaseConnection, series_ids: &[Uuid], ) -> Result>> { - 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> { - 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 21b8b1a5..6fa7c344 100644 --- a/src/db/repositories/task.rs +++ b/crates/codex-db/src/repositories/task.rs @@ -8,11 +8,10 @@ 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 crate::tasks::error::DEFAULT_MAX_RESCHEDULES; -use crate::tasks::types::{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). /// @@ -1139,7 +1138,7 @@ impl TaskRepository { /// Get queue statistics pub async fn get_stats(db: &DatabaseConnection) -> Result { - use crate::tasks::types::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/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 d067331f..553b48cc 100644 --- a/src/db/repositories/user_plugins.rs +++ b/crates/codex-db/src/repositories/user_plugins.rs @@ -15,10 +15,10 @@ #![allow(dead_code)] -use crate::db::entities::user_plugins::{self, Entity as UserPlugins}; -use crate::services::CredentialEncryption; +use crate::entities::user_plugins::{self, Entity as UserPlugins}; use anyhow::{Result, anyhow}; use chrono::{DateTime, Utc}; +use codex_utils::credential_encryption::CredentialEncryption; use sea_orm::*; use uuid::Uuid; @@ -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 98% rename from src/db/repositories/user_preferences.rs rename to crates/codex-db/src/repositories/user_preferences.rs index 5521a5f7..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,10 +288,10 @@ 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::utils::password; + 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 { let password_hash = password::hash_password("password").unwrap(); 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 76% rename from src/db/test_helpers.rs rename to crates/codex-db/src/test_helpers.rs index b5e98766..e718cbcf 100644 --- a/src/db/test_helpers.rs +++ b/crates/codex-db/src/test_helpers.rs @@ -1,17 +1,17 @@ -#[cfg(test)] -use crate::config::{DatabaseConfig, DatabaseType, SQLiteConfig}; -#[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}; 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..`. 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..` naming and the `db.system`, - /// `db.operation`, `otel.kind` fields being populated. + /// attributes. Callers can rely on the `db..` 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/crates/codex-events/Cargo.toml b/crates/codex-events/Cargo.toml new file mode 100644 index 00000000..68ef981a --- /dev/null +++ b/crates/codex-events/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "codex-events" +version.workspace = true +edition.workspace = true +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, + volume: Option, + language: Option, ) -> 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/crates/codex-models/Cargo.toml b/crates/codex-models/Cargo.toml new file mode 100644 index 00000000..15c68ecd --- /dev/null +++ b/crates/codex-models/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "codex-models" +version.workspace = true +edition.workspace = true +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/crates/codex-models/src/filter.rs b/crates/codex-models/src/filter.rs new file mode 100644 index 00000000..e71777ea --- /dev/null +++ b/crates/codex-models/src/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/crates/codex-models/src/lib.rs b/crates/codex-models/src/lib.rs new file mode 100644 index 00000000..000aceaa --- /dev/null +++ b/crates/codex-models/src/lib.rs @@ -0,0 +1,18 @@ +//! Cross-layer data models. +//! +//! 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 +//! `codex-models`, this crate depends on nothing else inside Codex). + +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/api/permissions.rs b/crates/codex-models/src/permissions.rs similarity index 100% rename from src/api/permissions.rs rename to crates/codex-models/src/permissions.rs diff --git a/crates/codex-models/src/plugin.rs b/crates/codex-models/src/plugin.rs new file mode 100644 index 00000000..34cccff7 --- /dev/null +++ b/crates/codex-models/src/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 `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`. + +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/services/metadata/preprocessing/types.rs b/crates/codex-models/src/preprocessing.rs similarity index 99% rename from src/services/metadata/preprocessing/types.rs rename to crates/codex-models/src/preprocessing.rs index a35f8c8b..3181a8bf 100644 --- a/src/services/metadata/preprocessing/types.rs +++ b/crates/codex-models/src/preprocessing.rs @@ -4,6 +4,8 @@ //! - Title preprocessing rules (regex-based transformations) //! - Auto-match conditions (conditional logic for plugin matching) //! - Condition operators (comparison operations) + +#![allow(dead_code)] //! //! ## Example: Preprocessing Rules //! diff --git a/crates/codex-models/src/release.rs b/crates/codex-models/src/release.rs new file mode 100644 index 00000000..e3ec133e --- /dev/null +++ b/crates/codex-models/src/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 +//! `codex::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 +/// `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 + /// 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/crates/codex-models/src/sort.rs b/crates/codex-models/src/sort.rs new file mode 100644 index 00000000..012f706a --- /dev/null +++ b/crates/codex-models/src/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/crates/codex-models/src/strategies.rs similarity index 98% rename from src/models.rs rename to crates/codex-models/src/strategies.rs index f65b6f95..56e5a9ac 100644 --- a/src/models.rs +++ b/crates/codex-models/src/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/tasks/types.rs b/crates/codex-models/src/task.rs similarity index 99% rename from src/tasks/types.rs rename to crates/codex-models/src/task.rs index 25cd0c6d..d4389b5c 100644 --- a/src/tasks/types.rs +++ b/crates/codex-models/src/task.rs @@ -8,6 +8,12 @@ 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")] diff --git a/crates/codex-parsers/Cargo.toml b/crates/codex-parsers/Cargo.toml new file mode 100644 index 00000000..2c0745a6 --- /dev/null +++ b/crates/codex-parsers/Cargo.toml @@ -0,0 +1,50 @@ +[package] +name = "codex-parsers" +version.workspace = true +edition.workspace = true +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 } + +# Error handling for ParserError +thiserror = "2.0" + +# 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 91% rename from src/parsers/cbr/parser.rs rename to crates/codex-parsers/src/cbr/parser.rs index 55092733..ffc2a43c 100644 --- a/src/parsers/cbr/parser.rs +++ b/crates/codex-parsers/src/cbr/parser.rs @@ -1,8 +1,9 @@ -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::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::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)))?; } } @@ -209,7 +210,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..0a97dbda 100644 --- a/src/parsers/cbz/parser.rs +++ b/crates/codex-parsers/src/cbz/parser.rs @@ -1,8 +1,9 @@ -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::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::hash_file; use std::fs::File; use std::io::Read; use std::path::Path; @@ -184,7 +185,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 97% rename from src/parsers/epub/parser.rs rename to crates/codex-parsers/src/epub/parser.rs index 83a1046d..5dff5e30 100644 --- a/src/parsers/epub/parser.rs +++ b/crates/codex-parsers/src/epub/parser.rs @@ -1,11 +1,12 @@ -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::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}; +use crate::opf; +use crate::traits::FormatParser; +use crate::{BookMetadata, FileFormat, ImageFormat, PageInfo}; use chrono::{DateTime, Utc}; +use codex_utils::hash_file; use image::GenericImageView; use std::collections::HashMap; use std::fs::File; @@ -139,10 +140,10 @@ 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()))?; + .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(), )) } @@ -211,13 +212,13 @@ 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)>)> { 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 @@ -721,7 +722,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 +861,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/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/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 97% rename from src/parsers/isbn_utils.rs rename to crates/codex-parsers/src/isbn_utils.rs index fcf392b3..2fd31446 100644 --- a/src/parsers/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 new file mode 100644 index 00000000..4959a793 --- /dev/null +++ b/crates/codex-parsers/src/lib.rs @@ -0,0 +1,23 @@ +//! Codex file-format parsers (CBZ, CBR, EPUB, PDF) and shared metadata +//! utilities. +//! +//! 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; +pub mod opf; +pub mod pdf; +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/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..08c7a6ca 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::error::{ParserError, Result}; +use crate::isbn_utils::extract_isbns; 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/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..c9a0b730 100644 --- a/src/parsers/pdf/parser.rs +++ b/crates/codex-parsers/src/pdf/parser.rs @@ -1,9 +1,10 @@ -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::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::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/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 98% rename from src/parsers/series_json.rs rename to crates/codex-parsers/src/series_json.rs index 965cbfa4..6f6df15a 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 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/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..7dcf528d 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 crate::error::Result; use std::path::Path; /// Trait for parsing different file formats diff --git a/crates/codex-scanner/Cargo.toml b/crates/codex-scanner/Cargo.toml new file mode 100644 index 00000000..907ec93a --- /dev/null +++ b/crates/codex-scanner/Cargo.toml @@ -0,0 +1,50 @@ +[package] +name = "codex-scanner" +version.workspace = true +edition.workspace = true +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 70% rename from src/scanner/analyzer.rs rename to crates/codex-scanner/src/analyzer.rs index f5213bcf..4f976f85 100644 --- a/src/scanner/analyzer.rs +++ b/crates/codex-scanner/src/analyzer.rs @@ -1,12 +1,12 @@ -use crate::parsers::BookMetadata; +use crate::detect_format; +use codex_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::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 std::path::Path; /// Analyze a file and extract metadata @@ -15,30 +15,30 @@ 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 { - 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 => { - return Err(CodexError::UnsupportedFormat( + codex_parsers::FileFormat::CBR => { + return Err(ParserError::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)? } @@ -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/src/scanner/analyzer_queue.rs b/crates/codex-scanner/src/analyzer_queue.rs similarity index 98% rename from src/scanner/analyzer_queue.rs rename to crates/codex-scanner/src/analyzer_queue.rs index 62d1a2db..23a671c8 100644 --- a/src/scanner/analyzer_queue.rs +++ b/crates/codex-scanner/src/analyzer_queue.rs @@ -8,22 +8,22 @@ 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::events::EventBroadcaster; -use crate::models::{BookStrategy, CalibreStrategyConfig, NumberStrategy, SeriesStrategy}; -use crate::parsers::opf; -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, }; -use crate::tasks::types::TaskType; -use crate::utils::normalize_for_search; +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::task::TaskType; +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); @@ -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 { @@ -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(); @@ -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))?; @@ -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(), @@ -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/crates/codex-scanner/src/detector.rs similarity index 99% rename from src/scanner/detector.rs rename to crates/codex-scanner/src/detector.rs index c028b055..195ba2cc 100644 --- a/src/scanner/detector.rs +++ b/crates/codex-scanner/src/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/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 98% rename from src/scanner/library_scanner.rs rename to crates/codex-scanner/src/library_scanner.rs index 136dd1a6..3cc93513 100644 --- a/src/scanner/library_scanner.rs +++ b/crates/codex-scanner/src/library_scanner.rs @@ -14,13 +14,11 @@ 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::events::{EventBroadcaster, TaskProgressEvent}; -use crate::models::SeriesStrategy; -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}; @@ -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; @@ -308,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), @@ -450,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); @@ -562,12 +560,12 @@ 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>>, 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; @@ -878,7 +876,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)) @@ -953,14 +951,14 @@ 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>, 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(); @@ -1265,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::db::repositories::SeriesMetadataRepository; - 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={:?}", @@ -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/strategies/book/custom.rs b/crates/codex-scanner/src/strategies/book/custom.rs similarity index 99% rename from src/scanner/strategies/book/custom.rs rename to crates/codex-scanner/src/strategies/book/custom.rs index 4b88483f..24e56050 100644 --- a/src/scanner/strategies/book/custom.rs +++ b/crates/codex-scanner/src/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/crates/codex-scanner/src/strategies/book/filename.rs similarity index 99% rename from src/scanner/strategies/book/filename.rs rename to crates/codex-scanner/src/strategies/book/filename.rs index 0a77c548..07543ad7 100644 --- a/src/scanner/strategies/book/filename.rs +++ b/crates/codex-scanner/src/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/crates/codex-scanner/src/strategies/book/metadata_first.rs similarity index 99% rename from src/scanner/strategies/book/metadata_first.rs rename to crates/codex-scanner/src/strategies/book/metadata_first.rs index f58ad894..a02dd006 100644 --- a/src/scanner/strategies/book/metadata_first.rs +++ b/crates/codex-scanner/src/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/crates/codex-scanner/src/strategies/book/mod.rs similarity index 99% rename from src/scanner/strategies/book/mod.rs rename to crates/codex-scanner/src/strategies/book/mod.rs index be3e5895..074b2272 100644 --- a/src/scanner/strategies/book/mod.rs +++ b/crates/codex-scanner/src/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/crates/codex-scanner/src/strategies/book/series_name.rs similarity index 99% rename from src/scanner/strategies/book/series_name.rs rename to crates/codex-scanner/src/strategies/book/series_name.rs index 999e977f..234d5ef5 100644 --- a/src/scanner/strategies/book/series_name.rs +++ b/crates/codex-scanner/src/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/crates/codex-scanner/src/strategies/book/smart.rs similarity index 99% rename from src/scanner/strategies/book/smart.rs rename to crates/codex-scanner/src/strategies/book/smart.rs index 8debebb7..63108568 100644 --- a/src/scanner/strategies/book/smart.rs +++ b/crates/codex-scanner/src/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/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 98% rename from src/scanner/strategies/number/file_order.rs rename to crates/codex-scanner/src/strategies/number/file_order.rs index 7192f4e6..2e7632e3 100644 --- a/src/scanner/strategies/number/file_order.rs +++ b/crates/codex-scanner/src/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/crates/codex-scanner/src/strategies/number/filename.rs similarity index 99% rename from src/scanner/strategies/number/filename.rs rename to crates/codex-scanner/src/strategies/number/filename.rs index 4389d3a2..414009a1 100644 --- a/src/scanner/strategies/number/filename.rs +++ b/crates/codex-scanner/src/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/crates/codex-scanner/src/strategies/number/metadata.rs similarity index 98% rename from src/scanner/strategies/number/metadata.rs rename to crates/codex-scanner/src/strategies/number/metadata.rs index 71ea157c..a2f3b8db 100644 --- a/src/scanner/strategies/number/metadata.rs +++ b/crates/codex-scanner/src/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/crates/codex-scanner/src/strategies/number/mod.rs similarity index 98% rename from src/scanner/strategies/number/mod.rs rename to crates/codex-scanner/src/strategies/number/mod.rs index 6d4f0d97..810c8aef 100644 --- a/src/scanner/strategies/number/mod.rs +++ b/crates/codex-scanner/src/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/crates/codex-scanner/src/strategies/number/smart.rs similarity index 99% rename from src/scanner/strategies/number/smart.rs rename to crates/codex-scanner/src/strategies/number/smart.rs index bf739f5c..4b8e6c60 100644 --- a/src/scanner/strategies/number/smart.rs +++ b/crates/codex-scanner/src/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/crates/codex-scanner/src/strategies/series/calibre.rs similarity index 99% rename from src/scanner/strategies/series/calibre.rs rename to crates/codex-scanner/src/strategies/series/calibre.rs index 16bcbc0e..cddd8b0d 100644 --- a/src/scanner/strategies/series/calibre.rs +++ b/crates/codex-scanner/src/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/crates/codex-scanner/src/strategies/series/custom.rs similarity index 99% rename from src/scanner/strategies/series/custom.rs rename to crates/codex-scanner/src/strategies/series/custom.rs index a118cf5b..46558d85 100644 --- a/src/scanner/strategies/series/custom.rs +++ b/crates/codex-scanner/src/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/crates/codex-scanner/src/strategies/series/flat.rs similarity index 99% rename from src/scanner/strategies/series/flat.rs rename to crates/codex-scanner/src/strategies/series/flat.rs index 976fb7af..d36e49fc 100644 --- a/src/scanner/strategies/series/flat.rs +++ b/crates/codex-scanner/src/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/crates/codex-scanner/src/strategies/series/mod.rs similarity index 99% rename from src/scanner/strategies/series/mod.rs rename to crates/codex-scanner/src/strategies/series/mod.rs index c3fd4664..74d23ec9 100644 --- a/src/scanner/strategies/series/mod.rs +++ b/crates/codex-scanner/src/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/crates/codex-scanner/src/strategies/series/publisher_hierarchy.rs similarity index 99% rename from src/scanner/strategies/series/publisher_hierarchy.rs rename to crates/codex-scanner/src/strategies/series/publisher_hierarchy.rs index faf63afc..3cad9493 100644 --- a/src/scanner/strategies/series/publisher_hierarchy.rs +++ b/crates/codex-scanner/src/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/crates/codex-scanner/src/strategies/series/series_volume.rs similarity index 99% rename from src/scanner/strategies/series/series_volume.rs rename to crates/codex-scanner/src/strategies/series/series_volume.rs index 08b34547..107d0106 100644 --- a/src/scanner/strategies/series/series_volume.rs +++ b/crates/codex-scanner/src/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/crates/codex-scanner/src/strategies/series/series_volume_chapter.rs similarity index 99% rename from src/scanner/strategies/series/series_volume_chapter.rs rename to crates/codex-scanner/src/strategies/series/series_volume_chapter.rs index c55e4bde..64c02c79 100644 --- a/src/scanner/strategies/series/series_volume_chapter.rs +++ b/crates/codex-scanner/src/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/scanner/types.rs b/crates/codex-scanner/src/types.rs similarity index 99% rename from src/scanner/types.rs rename to crates/codex-scanner/src/types.rs index a7db1836..e4bc4be7 100644 --- a/src/scanner/types.rs +++ b/crates/codex-scanner/src/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/crates/codex-scheduler/Cargo.toml b/crates/codex-scheduler/Cargo.toml new file mode 100644 index 00000000..bfe0b49c --- /dev/null +++ b/crates/codex-scheduler/Cargo.toml @@ -0,0 +1,40 @@ +[package] +name = "codex-scheduler" +version.workspace = true +edition.workspace = true +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 95% rename from src/scheduler/mod.rs rename to crates/codex-scheduler/src/lib.rs index 9746a894..f590bb7e 100644 --- a/src/scheduler/mod.rs +++ b/crates/codex-scheduler/src/lib.rs @@ -7,13 +7,13 @@ 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 crate::utils::cron::{normalize_cron_expression, parse_timezone}; +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.) pub struct Scheduler { @@ -742,12 +742,35 @@ impl Scheduler { } } +/// Adapter that lets the `services` layer drive a `Scheduler` through the +/// [`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 { + 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 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; + guard.reconcile_release_sources().await + }) + } +} + /// Whether an active (`pending` or `processing`) `refresh_library_metadata` /// task already exists for the given **job**. /// /// `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}; @@ -784,10 +807,10 @@ 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::models::ScanningStrategy; - 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/crates/codex-scheduler/src/release_sources.rs similarity index 95% rename from src/scheduler/release_sources.rs rename to crates/codex-scheduler/src/release_sources.rs index 8563b31f..9ccc9c65 100644 --- a/src/scheduler/release_sources.rs +++ b/crates/codex-scheduler/src/release_sources.rs @@ -23,11 +23,11 @@ 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 crate::utils::cron::normalize_cron_expression; +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. #[derive(Debug, Default)] @@ -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/crates/codex-search/Cargo.toml b/crates/codex-search/Cargo.toml new file mode 100644 index 00000000..eb3142c9 --- /dev/null +++ b/crates/codex-search/Cargo.toml @@ -0,0 +1,38 @@ +[package] +name = "codex-search" +version.workspace = true +edition.workspace = true +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 98% rename from src/search/builder.rs rename to crates/codex-search/src/builder.rs index 4acb7088..86ba276e 100644 --- a/src/search/builder.rs +++ b/crates/codex-search/src/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/index.rs b/crates/codex-search/src/index.rs similarity index 99% rename from src/search/index.rs rename to crates/codex-search/src/index.rs index bb7236e9..114e2b60 100644 --- a/src/search/index.rs +++ b/crates/codex-search/src/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/search/mod.rs b/crates/codex-search/src/lib.rs similarity index 94% rename from src/search/mod.rs rename to crates/codex-search/src/lib.rs index 4783e50a..7981a206 100644 --- a/src/search/mod.rs +++ b/crates/codex-search/src/lib.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/search/listener.rs b/crates/codex-search/src/listener.rs similarity index 98% rename from src/search/listener.rs rename to crates/codex-search/src/listener.rs index 7b7850e6..b4e58083 100644 --- a/src/search/listener.rs +++ b/crates/codex-search/src/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}; @@ -213,16 +213,16 @@ 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::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::events::EntityChangeEvent; - 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/crates/codex-services/Cargo.toml b/crates/codex-services/Cargo.toml new file mode 100644 index 00000000..83ad0758 --- /dev/null +++ b/crates/codex-services/Cargo.toml @@ -0,0 +1,117 @@ +[package] +name = "codex-services" +version.workspace = true +edition.workspace = true +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 98% rename from src/services/auth_tracking.rs rename to crates/codex-services/src/auth_tracking.rs index 71fe4e67..0bdaa6ba 100644 --- a/src/services/auth_tracking.rs +++ b/crates/codex-services/src/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,10 +190,10 @@ 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 crate::utils::password; + 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; async fn create_test_user(db: &DatabaseConnection) -> users::Model { diff --git a/src/services/book_export_collector.rs b/crates/codex-services/src/book_export_collector.rs similarity index 98% rename from src/services/book_export_collector.rs rename to crates/codex-services/src/book_export_collector.rs index 1f4e81f3..b9dfcb5f 100644 --- a/src/services/book_export_collector.rs +++ b/crates/codex-services/src/book_export_collector.rs @@ -11,9 +11,9 @@ 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::{ +use crate::content_filter::ContentFilter; +use codex_db::entities::{book_metadata, books, read_progress}; +use codex_db::repositories::{ GenreRepository, LibraryRepository, ReadProgressRepository, SeriesRepository, TagRepository, }; @@ -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/crates/codex-services/src/cleanup_subscriber.rs similarity index 98% rename from src/services/cleanup_subscriber.rs rename to crates/codex-services/src/cleanup_subscriber.rs index e75c9b1c..fc774691 100644 --- a/src/services/cleanup_subscriber.rs +++ b/crates/codex-services/src/cleanup_subscriber.rs @@ -8,9 +8,9 @@ use std::sync::Arc; use tokio::sync::broadcast; use tracing::{debug, error, info, warn}; -use crate::db::repositories::TaskRepository; -use crate::events::{EntityChangeEvent, EntityEvent, EventBroadcaster}; -use crate::tasks::types::TaskType; +use codex_db::repositories::TaskRepository; +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 { @@ -189,10 +189,10 @@ impl CleanupEventSubscriber { #[cfg(test)] mod tests { use super::*; - use crate::db::test_helpers::create_test_db; - use crate::events::EventBroadcaster; - use crate::tasks::types::TaskType; use chrono::Utc; + use codex_db::test_helpers::create_test_db; + use codex_events::EventBroadcaster; + use codex_models::task::TaskType; use uuid::Uuid; #[tokio::test] diff --git a/src/api/extractors/content_filter.rs b/crates/codex-services/src/content_filter.rs similarity index 99% rename from src/api/extractors/content_filter.rs rename to crates/codex-services/src/content_filter.rs index 36739dec..a23df28b 100644 --- a/src/api/extractors/content_filter.rs +++ b/crates/codex-services/src/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/email.rs b/crates/codex-services/src/email.rs similarity index 99% rename from src/services/email.rs rename to crates/codex-services/src/email.rs index 0c356adf..e745b172 100644 --- a/src/services/email.rs +++ b/crates/codex-services/src/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/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 99% rename from src/services/file_cleanup.rs rename to crates/codex-services/src/file_cleanup.rs index c4c2f989..0bbeb529 100644 --- a/src/services/file_cleanup.rs +++ b/crates/codex-services/src/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/filter.rs b/crates/codex-services/src/filter.rs similarity index 98% rename from src/services/filter.rs rename to crates/codex-services/src/filter.rs index 4b39db78..a2931e6d 100644 --- a/src/services/filter.rs +++ b/crates/codex-services/src/filter.rs @@ -4,12 +4,12 @@ #![allow(dead_code)] -use crate::api::routes::v1::dto::{ +use anyhow::Result; +use codex_db::repositories::{GenreRepository, TagRepository}; +use codex_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; use std::future::Future; @@ -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)); @@ -2515,9 +2515,7 @@ impl FilterService { #[cfg(test)] mod tests { use super::*; - use crate::api::routes::v1::dto::{ - 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/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 76% rename from src/services/mod.rs rename to crates/codex-services/src/lib.rs index 12ff3b88..7c34bdf1 100644 --- a/src/services/mod.rs +++ b/crates/codex-services/src/lib.rs @@ -1,6 +1,16 @@ 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; pub mod export_storage; pub mod file_cleanup; @@ -19,6 +29,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 +57,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 `codex_utils::credential_encryption`. +#[allow(unused_imports)] +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/library_jobs/mod.rs b/crates/codex-services/src/library_jobs/mod.rs similarity index 83% rename from src/services/library_jobs/mod.rs rename to crates/codex-services/src/library_jobs/mod.rs index cff40409..5a59d257 100644 --- a/src/services/library_jobs/mod.rs +++ b/crates/codex-services/src/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/crates/codex-services/src/library_jobs/types.rs similarity index 99% rename from src/services/library_jobs/types.rs rename to crates/codex-services/src/library_jobs/types.rs index 6081138d..b9c6fcf2 100644 --- a/src/services/library_jobs/types.rs +++ b/crates/codex-services/src/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/crates/codex-services/src/library_jobs/validation.rs similarity index 96% rename from src/services/library_jobs/validation.rs rename to crates/codex-services/src/library_jobs/validation.rs index ee8dbdb0..61c2b361 100644 --- a/src/services/library_jobs/validation.rs +++ b/crates/codex-services/src/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,9 +12,9 @@ use thiserror::Error; use std::str::FromStr; -use crate::db::repositories::PluginsRepository; -use crate::services::metadata::FieldGroup; -use crate::utils::cron::{validate_cron_expression, validate_timezone}; +use crate::metadata::FieldGroup; +use codex_db::repositories::PluginsRepository; +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/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 39af09f3..05dc4b3e 100644 --- a/src/services/metadata/apply.rs +++ b/crates/codex-services/src/metadata/apply.rs @@ -12,16 +12,16 @@ 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::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; +use codex_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/crates/codex-services/src/metadata/book_apply.rs similarity index 98% rename from src/services/metadata/book_apply.rs rename to crates/codex-services/src/metadata/book_apply.rs index 47a60ee8..fc874a87 100644 --- a/src/services/metadata/book_apply.rs +++ b/crates/codex-services/src/metadata/book_apply.rs @@ -11,12 +11,12 @@ 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::events::EventBroadcaster; -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}; +use codex_events::EventBroadcaster; use super::CoverService; use super::apply::SkippedField; diff --git a/src/services/metadata/cover.rs b/crates/codex-services/src/metadata/cover.rs similarity index 97% rename from src/services/metadata/cover.rs rename to crates/codex-services/src/metadata/cover.rs index d09764a6..2f71ec82 100644 --- a/src/services/metadata/cover.rs +++ b/crates/codex-services/src/metadata/cover.rs @@ -9,12 +9,12 @@ use std::sync::Arc; use tracing::warn; use uuid::Uuid; -use crate::db::repositories::{ +use crate::ThumbnailService; +use codex_db::repositories::{ BookCoversRepository, SeriesCoversRepository, SeriesRepository, TaskRepository, }; -use crate::events::{EntityChangeEvent, EntityEvent, EntityType, EventBroadcaster}; -use crate::services::ThumbnailService; -use crate::tasks::types::TaskType; +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/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 99% rename from src/services/metadata/preprocessing/context.rs rename to crates/codex-services/src/metadata/preprocessing/context.rs index eb8dd93c..0a78ef9f 100644 --- a/src/services/metadata/preprocessing/context.rs +++ b/crates/codex-services/src/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/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/crates/codex-services/src/metadata/preprocessing/types.rs b/crates/codex-services/src/metadata/preprocessing/types.rs new file mode 100644 index 00000000..d781064f --- /dev/null +++ b/crates/codex-services/src/metadata/preprocessing/types.rs @@ -0,0 +1,8 @@ +//! Re-export of preprocessing value types. +//! +//! 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 codex_models::preprocessing::*; diff --git a/src/services/metadata/refresh_planner.rs b/crates/codex-services/src/metadata/refresh_planner.rs similarity index 95% rename from src/services/metadata/refresh_planner.rs rename to crates/codex-services/src/metadata/refresh_planner.rs index 801a5a8e..29433c4b 100644 --- a/src/services/metadata/refresh_planner.rs +++ b/crates/codex-services/src/metadata/refresh_planner.rs @@ -15,11 +15,11 @@ 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; +use crate::library_jobs::MetadataRefreshJobConfig; /// Reason a series was skipped during planning. #[derive(Debug, Clone, PartialEq, Eq)] @@ -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 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}; + use codex_db::test_helpers::setup_test_db; use std::env; use std::sync::Once; 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 99% rename from src/services/oidc.rs rename to crates/codex-services/src/oidc.rs index a3a173e0..d61bb8d0 100644 --- a/src/services/oidc.rs +++ b/crates/codex-services/src/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_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 97% rename from src/services/pdf_handle_cache_subscriber.rs rename to crates/codex-services/src/pdf_handle_cache_subscriber.rs index e782e2c6..a1625853 100644 --- a/src/services/pdf_handle_cache_subscriber.rs +++ b/crates/codex-services/src/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 crate::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/crates/codex-services/src/plugin/handle.rs similarity index 98% rename from src/services/plugin/handle.rs rename to crates/codex-services/src/plugin/handle.rs index 1bb7099c..d7c7a003 100644 --- a/src/services/plugin/handle.rs +++ b/crates/codex-services/src/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::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::scheduler_handle::SharedSchedulerReconciler, ) -> Self { self.scheduler = Some(scheduler); self @@ -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/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 97% rename from src/services/plugin/library.rs rename to crates/codex-services/src/plugin/library.rs index 5d8056e2..6dffd457 100644 --- a/src/services/plugin/library.rs +++ b/crates/codex-services/src/plugin/library.rs @@ -9,15 +9,13 @@ use std::collections::HashMap; use tracing::{debug, warn}; use uuid::Uuid; -use crate::db::entities::SeriesStatus; -use crate::db::repositories::{ +use crate::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/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 178e6353..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::db::entities::plugins; -use crate::db::entities::user_plugins; -use crate::db::repositories::{ +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::PluginMetricsService; -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<Arc<tokio::sync::Mutex<crate::scheduler::Scheduler>>>, + 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: Arc<tokio::sync::Mutex<crate::scheduler::Scheduler>>, + 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 99% rename from src/services/plugin/mod.rs rename to crates/codex-services/src/plugin/mod.rs index 40c8d437..d9cf9bc7 100644 --- a/src/services/plugin/mod.rs +++ b/crates/codex-services/src/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/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 85% rename from src/services/plugin/protocol.rs rename to crates/codex-services/src/plugin/protocol.rs index ac0dfa2b..39d03135 100644 --- a/src/services/plugin/protocol.rs +++ b/crates/codex-services/src/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 `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 codex_models::plugin::{ + CredentialField, CredentialType, MetadataContentType, OAuthConfig, PluginCapabilities, + PluginManifest, PluginManifestType, PluginScope, ReleaseSourceCapability, ReleaseSourceKind, +}; // ============================================================================= // Metadata Types @@ -1245,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)] @@ -1476,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 97% rename from src/services/plugin/releases_handler.rs rename to crates/codex-services/src/plugin/releases_handler.rs index 75c5fc29..4664c839 100644 --- a/src/services/plugin/releases_handler.rs +++ b/crates/codex-services/src/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; @@ -25,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::{ +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::{ 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}; /// 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 } @@ -354,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!( @@ -367,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"); @@ -403,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, @@ -475,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,17 +588,19 @@ 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, + 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, series_title, + outcome.row.source_id, + &self.plugin_name, + outcome.row.chapter, + outcome.row.volume, + outcome.row.language.clone(), )); } else { debug!( @@ -628,7 +627,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, @@ -759,7 +758,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 { @@ -922,11 +921,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 { @@ -1208,17 +1206,18 @@ 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::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::{ 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; fn make_capability( requires_aliases: bool, @@ -1465,7 +1464,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(); @@ -1907,7 +1906,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(); @@ -1919,7 +1918,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, @@ -1931,9 +1930,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 }), ) @@ -1955,7 +1954,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(); @@ -1967,7 +1966,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, @@ -1975,9 +1974,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/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 98% rename from src/services/plugin/storage_handler.rs rename to crates/codex-services/src/plugin/storage_handler.rs index c8f97670..fc710a53 100644 --- a/src/services/plugin/storage_handler.rs +++ b/crates/codex-services/src/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 crate::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/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 99% rename from src/services/rate_limiter.rs rename to crates/codex-services/src/rate_limiter.rs index a4fb519b..459809db 100644 --- a/src/services/rate_limiter.rs +++ b/crates/codex-services/src/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/read_progress.rs b/crates/codex-services/src/read_progress.rs similarity index 98% rename from src/services/read_progress.rs rename to crates/codex-services/src/read_progress.rs index 0662b35d..dafcdf15 100644 --- a/src/services/read_progress.rs +++ b/crates/codex-services/src/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,14 +212,14 @@ 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 crate::models::ScanningStrategy; - use crate::utils::password; - use chrono::Utc; + use codex_db::test_helpers::setup_test_db; + 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/refresh_token.rs b/crates/codex-services/src/refresh_token.rs similarity index 98% rename from src/services/refresh_token.rs rename to crates/codex-services/src/refresh_token.rs index 1c8be1ca..4e17e8f5 100644 --- a/src/services/refresh_token.rs +++ b/crates/codex-services/src/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::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 codex_db::Database; + use codex_db::entities::users; + use codex_db::repositories::UserRepository; use std::collections::HashMap; use tempfile::TempDir; 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 95% rename from src/services/release/auto_ignore.rs rename to crates/codex-services/src/release/auto_ignore.rs index 85b3b79a..0f455da9 100644 --- a/src/services/release/auto_ignore.rs +++ b/crates/codex-services/src/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. @@ -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 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/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 80% rename from src/services/release/candidate.rs rename to crates/codex-services/src/release/candidate.rs index bebb94d3..74b920d4 100644 --- a/src/services/release/candidate.rs +++ b/crates/codex-services/src/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 `codex_models::release` so the +// db layer can consume them without importing services. +#[allow(unused_imports)] +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/languages.rs b/crates/codex-services/src/release/languages.rs similarity index 99% rename from src/services/release/languages.rs rename to crates/codex-services/src/release/languages.rs index ad9659f1..bc90df9b 100644 --- a/src/services/release/languages.rs +++ b/crates/codex-services/src/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/crates/codex-services/src/release/matcher.rs similarity index 96% rename from src/services/release/matcher.rs rename to crates/codex-services/src/release/matcher.rs index 71af8711..46434017 100644 --- a/src/services/release/matcher.rs +++ b/crates/codex-services/src/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; @@ -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 96% rename from src/services/release/schedule.rs rename to crates/codex-services/src/release/schedule.rs index 001f15c0..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). @@ -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/release/seed.rs b/crates/codex-services/src/release/seed.rs similarity index 98% rename from src/services/release/seed.rs rename to crates/codex-services/src/release/seed.rs index a88ce2e9..2fe1082b 100644 --- a/src/services/release/seed.rs +++ b/crates/codex-services/src/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/crates/codex-services/src/release/tracking_toggle.rs similarity index 97% rename from src/services/release/tracking_toggle.rs rename to crates/codex-services/src/release/tracking_toggle.rs index b59e9925..f98d7fd2 100644 --- a/src/services/release/tracking_toggle.rs +++ b/crates/codex-services/src/release/tracking_toggle.rs @@ -13,9 +13,9 @@ use serde::{Deserialize, Serialize}; 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 crate::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/crates/codex-services/src/release/upstream_gap.rs similarity index 99% rename from src/services/release/upstream_gap.rs rename to crates/codex-services/src/release/upstream_gap.rs index f3a93de2..3beae68f 100644 --- a/src/services/release/upstream_gap.rs +++ b/crates/codex-services/src/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/crates/codex-services/src/scheduler_handle.rs b/crates/codex-services/src/scheduler_handle.rs new file mode 100644 index 00000000..d45ad052 --- /dev/null +++ b/crates/codex-services/src/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/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 f8cb9ea2..b7a2d7d5 100644 --- a/src/services/series_export_collector.rs +++ b/crates/codex-services/src/series_export_collector.rs @@ -11,9 +11,9 @@ 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::{ +use crate::content_filter::ContentFilter; +use codex_db::entities::series; +use codex_db::repositories::{ AlternateTitleRepository, BookRepository, ExternalRatingRepository, GenreRepository, LibraryRepository, SeriesMetadataRepository, SeriesRepository, TagRepository, UserSeriesRatingRepository, @@ -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/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 98% rename from src/services/settings.rs rename to crates/codex-services/src/settings.rs index 590b7e4e..e369458f 100644 --- a/src/services/settings.rs +++ b/crates/codex-services/src/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/crates/codex-services/src/task_listener.rs similarity index 99% rename from src/services/task_listener.rs rename to crates/codex-services/src/task_listener.rs index 624fe4fb..3c6ee424 100644 --- a/src/services/task_listener.rs +++ b/crates/codex-services/src/task_listener.rs @@ -8,13 +8,13 @@ //! result. This service replays those events when tasks complete, bridging //! 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_db::repositories::TaskRepository; +use codex_events::{ + EntityChangeEvent, EventBroadcaster, RecordedEvent, TaskProgressEvent, TaskStatus, +}; use sea_orm::{ DatabaseConnection, SqlxPostgresPoolConnection, sqlx::{PgPool, postgres::PgListener}, 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 c656c698..19fd54c6 100644 --- a/src/services/task_metrics.rs +++ b/crates/codex-services/src/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 crate::SettingsService; +use codex_db::repositories::task_metrics::{TaskCompletionData, TaskMetricsRepository}; /// Number of recent completions to keep for percentile calculation const MAX_RECENT_COMPLETIONS: usize = 1000; @@ -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, @@ -802,7 +797,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/crates/codex-services/src/thumbnail.rs similarity index 98% rename from src/services/thumbnail.rs rename to crates/codex-services/src/thumbnail.rs index 2def74e6..5c5a2843 100644 --- a/src/services/thumbnail.rs +++ b/crates/codex-services/src/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_db::entities::books; +use codex_db::repositories::{BookRepository, SeriesRepository, SettingsRepository}; +use codex_events::{EntityChangeEvent, EntityEvent, EntityType, EventBroadcaster}; // ============================================================================ // Placeholder Thumbnail Generation @@ -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/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 3f5b2401..6ac3c699 100644 --- a/src/services/user_plugin/token_refresh.rs +++ b/crates/codex-services/src/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 crate::plugin::protocol::OAuthConfig; +use codex_db::entities::user_plugins; +use codex_db::repositories::UserPluginsRepository; use super::oauth::OAuthTokenResponse; diff --git a/crates/codex-tasks/Cargo.toml b/crates/codex-tasks/Cargo.toml new file mode 100644 index 00000000..ce2594fd --- /dev/null +++ b/crates/codex-tasks/Cargo.toml @@ -0,0 +1,48 @@ +[package] +name = "codex-tasks" +version.workspace = true +edition.workspace = true +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 95% rename from src/tasks/error.rs rename to crates/codex-tasks/src/error.rs index 4b4985d9..52e5d6a6 100644 --- a/src/tasks/error.rs +++ b/crates/codex-tasks/src/error.rs @@ -12,13 +12,13 @@ //! 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; -/// 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 `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 codex_models::task::{DEFAULT_MAX_RESCHEDULES, DEFAULT_RATE_LIMIT_RETRY_SECONDS}; /// Trait for errors that represent rate limiting /// @@ -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/analyze_book.rs b/crates/codex-tasks/src/handlers/analyze_book.rs similarity index 93% rename from src/tasks/handlers/analyze_book.rs rename to crates/codex-tasks/src/handlers/analyze_book.rs index a9673669..f0b19ee5 100644 --- a/src/tasks/handlers/analyze_book.rs +++ b/crates/codex-tasks/src/handlers/analyze_book.rs @@ -4,12 +4,12 @@ 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::events::{EventBroadcaster, TaskProgressEvent}; -use crate::scanner::analyze_book; -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}; +use codex_scanner::analyze_book; pub struct AnalyzeBookHandler; diff --git a/src/tasks/handlers/analyze_series.rs b/crates/codex-tasks/src/handlers/analyze_series.rs similarity index 94% rename from src/tasks/handlers/analyze_series.rs rename to crates/codex-tasks/src/handlers/analyze_series.rs index 92557760..09ad8d05 100644 --- a/src/tasks/handlers/analyze_series.rs +++ b/crates/codex-tasks/src/handlers/analyze_series.rs @@ -4,11 +4,11 @@ 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::events::{EventBroadcaster, TaskProgressEvent}; -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}; pub struct AnalyzeSeriesHandler; diff --git a/src/tasks/handlers/backfill_tracking.rs b/crates/codex-tasks/src/handlers/backfill_tracking.rs similarity index 96% rename from src/tasks/handlers/backfill_tracking.rs rename to crates/codex-tasks/src/handlers/backfill_tracking.rs index 106d486c..1c13b905 100644 --- a/src/tasks/handlers/backfill_tracking.rs +++ b/crates/codex-tasks/src/handlers/backfill_tracking.rs @@ -17,12 +17,12 @@ use std::sync::Arc; use tracing::{info, warn}; 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 crate::handlers::TaskHandler; +use crate::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; @@ -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/crates/codex-tasks/src/handlers/bulk_track_for_releases.rs similarity index 97% rename from src/tasks/handlers/bulk_track_for_releases.rs rename to crates/codex-tasks/src/handlers/bulk_track_for_releases.rs index 498cc793..488dd11c 100644 --- a/src/tasks/handlers/bulk_track_for_releases.rs +++ b/crates/codex-tasks/src/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::db::entities::tasks; -use crate::events::{EventBroadcaster, TaskProgressEvent}; -use crate::services::release::tracking_toggle::{ +use crate::handlers::TaskHandler; +use crate::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, }; -use crate::tasks::handlers::TaskHandler; -use crate::tasks::types::TaskResult; pub struct BulkTrackForReleasesHandler; @@ -194,13 +194,13 @@ fn emit_progress( mod tests { use super::*; - use crate::db::ScanningStrategy; - use crate::db::repositories::{ + use crate::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/crates/codex-tasks/src/handlers/cleanup_book_files.rs similarity index 97% rename from src/tasks/handlers/cleanup_book_files.rs rename to crates/codex-tasks/src/handlers/cleanup_book_files.rs index e402f7d5..7bff8f2c 100644 --- a/src/tasks/handlers/cleanup_book_files.rs +++ b/crates/codex-tasks/src/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 crate::handlers::TaskHandler; +use crate::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/crates/codex-tasks/src/handlers/cleanup_orphaned_files.rs similarity index 95% rename from src/tasks/handlers/cleanup_orphaned_files.rs rename to crates/codex-tasks/src/handlers/cleanup_orphaned_files.rs index a79f207d..3618bc5e 100644 --- a/src/tasks/handlers/cleanup_orphaned_files.rs +++ b/crates/codex-tasks/src/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 crate::handlers::TaskHandler; +use crate::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/crates/codex-tasks/src/handlers/cleanup_pdf_cache.rs similarity index 95% rename from src/tasks/handlers/cleanup_pdf_cache.rs rename to crates/codex-tasks/src/handlers/cleanup_pdf_cache.rs index 485bb2b4..f1fb2fae 100644 --- a/src/tasks/handlers/cleanup_pdf_cache.rs +++ b/crates/codex-tasks/src/handlers/cleanup_pdf_cache.rs @@ -9,11 +9,11 @@ use serde_json::json; 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 crate::handlers::TaskHandler; +use crate::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/crates/codex-tasks/src/handlers/cleanup_plugin_data.rs similarity index 93% rename from src/tasks/handlers/cleanup_plugin_data.rs rename to crates/codex-tasks/src/handlers/cleanup_plugin_data.rs index 4676b0a6..0004a51f 100644 --- a/src/tasks/handlers/cleanup_plugin_data.rs +++ b/crates/codex-tasks/src/handlers/cleanup_plugin_data.rs @@ -11,12 +11,12 @@ use serde_json::json; use std::sync::Arc; 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 crate::handlers::TaskHandler; +use crate::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_refresh_tokens.rs b/crates/codex-tasks/src/handlers/cleanup_refresh_tokens.rs similarity index 93% rename from src/tasks/handlers/cleanup_refresh_tokens.rs rename to crates/codex-tasks/src/handlers/cleanup_refresh_tokens.rs index 0c00b371..35de0465 100644 --- a/src/tasks/handlers/cleanup_refresh_tokens.rs +++ b/crates/codex-tasks/src/handlers/cleanup_refresh_tokens.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::RefreshTokenRepository; -use crate::events::EventBroadcaster; -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; /// 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 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/crates/codex-tasks/src/handlers/cleanup_series_exports.rs similarity index 94% rename from src/tasks/handlers/cleanup_series_exports.rs rename to crates/codex-tasks/src/handlers/cleanup_series_exports.rs index 44ea86a8..9edf3cab 100644 --- a/src/tasks/handlers/cleanup_series_exports.rs +++ b/crates/codex-tasks/src/handlers/cleanup_series_exports.rs @@ -11,13 +11,13 @@ use serde_json::json; use std::sync::Arc; 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 crate::handlers::TaskHandler; +use crate::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; @@ -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/crates/codex-tasks/src/handlers/cleanup_series_files.rs similarity index 95% rename from src/tasks/handlers/cleanup_series_files.rs rename to crates/codex-tasks/src/handlers/cleanup_series_files.rs index 69d8b7da..871e61d0 100644 --- a/src/tasks/handlers/cleanup_series_files.rs +++ b/crates/codex-tasks/src/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 crate::handlers::TaskHandler; +use crate::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/crates/codex-tasks/src/handlers/export_series.rs similarity index 96% rename from src/tasks/handlers/export_series.rs rename to crates/codex-tasks/src/handlers/export_series.rs index d8e6d413..44543423 100644 --- a/src/tasks/handlers/export_series.rs +++ b/crates/codex-tasks/src/handlers/export_series.rs @@ -13,16 +13,16 @@ use std::sync::Arc; use tracing::{error, info, warn}; 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; -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 crate::handlers::TaskHandler; +use crate::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; @@ -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/crates/codex-tasks/src/handlers/find_duplicates.rs similarity index 95% rename from src/tasks/handlers/find_duplicates.rs rename to crates/codex-tasks/src/handlers/find_duplicates.rs index ff156490..288cda92 100644 --- a/src/tasks/handlers/find_duplicates.rs +++ b/crates/codex-tasks/src/handlers/find_duplicates.rs @@ -3,12 +3,12 @@ use sea_orm::DatabaseConnection; use std::sync::Arc; use tracing::{info, warn}; -use crate::db::entities::tasks; -use crate::db::repositories::{ +use crate::types::TaskResult; +use codex_db::entities::tasks; +use codex_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/crates/codex-tasks/src/handlers/generate_series_thumbnail.rs similarity index 94% rename from src/tasks/handlers/generate_series_thumbnail.rs rename to crates/codex-tasks/src/handlers/generate_series_thumbnail.rs index baae28af..d4a413d8 100644 --- a/src/tasks/handlers/generate_series_thumbnail.rs +++ b/crates/codex-tasks/src/handlers/generate_series_thumbnail.rs @@ -8,12 +8,12 @@ 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::events::{EntityChangeEvent, EntityEvent, EntityType, EventBroadcaster}; -use crate::services::ThumbnailService; -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}; +use codex_services::ThumbnailService; pub struct GenerateSeriesThumbnailHandler { thumbnail_service: Arc<ThumbnailService>, @@ -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/handlers/generate_series_thumbnails.rs b/crates/codex-tasks/src/handlers/generate_series_thumbnails.rs similarity index 96% rename from src/tasks/handlers/generate_series_thumbnails.rs rename to crates/codex-tasks/src/handlers/generate_series_thumbnails.rs index 8feb962e..6317adb1 100644 --- a/src/tasks/handlers/generate_series_thumbnails.rs +++ b/crates/codex-tasks/src/handlers/generate_series_thumbnails.rs @@ -9,12 +9,12 @@ 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::events::{EventBroadcaster, TaskProgressEvent}; -use crate::services::ThumbnailService; -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}; +use codex_services::ThumbnailService; pub struct GenerateSeriesThumbnailsHandler { thumbnail_service: Arc<ThumbnailService>, diff --git a/src/tasks/handlers/generate_thumbnail.rs b/crates/codex-tasks/src/handlers/generate_thumbnail.rs similarity index 94% rename from src/tasks/handlers/generate_thumbnail.rs rename to crates/codex-tasks/src/handlers/generate_thumbnail.rs index e3dda236..ccd4702f 100644 --- a/src/tasks/handlers/generate_thumbnail.rs +++ b/crates/codex-tasks/src/handlers/generate_thumbnail.rs @@ -3,13 +3,13 @@ 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::events::{EntityChangeEvent, EntityEvent, EntityType, EventBroadcaster}; -use crate::services::ThumbnailService; -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}; +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/crates/codex-tasks/src/handlers/generate_thumbnails.rs similarity index 96% rename from src/tasks/handlers/generate_thumbnails.rs rename to crates/codex-tasks/src/handlers/generate_thumbnails.rs index cc3ea70b..02281b21 100644 --- a/src/tasks/handlers/generate_thumbnails.rs +++ b/crates/codex-tasks/src/handlers/generate_thumbnails.rs @@ -3,12 +3,12 @@ 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::events::{EventBroadcaster, TaskProgressEvent}; -use crate::services::ThumbnailService; -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}; +use codex_services::ThumbnailService; pub struct GenerateThumbnailsHandler { thumbnail_service: Arc<ThumbnailService>, diff --git a/src/tasks/handlers/mod.rs b/crates/codex-tasks/src/handlers/mod.rs similarity index 96% rename from src/tasks/handlers/mod.rs rename to crates/codex-tasks/src/handlers/mod.rs index e9919d6f..6ea32538 100644 --- a/src/tasks/handlers/mod.rs +++ b/crates/codex-tasks/src/handlers/mod.rs @@ -2,9 +2,9 @@ use anyhow::Result; use sea_orm::DatabaseConnection; use std::sync::Arc; -use crate::db::entities::tasks; -use crate::events::EventBroadcaster; -use crate::tasks::types::TaskResult; +use crate::types::TaskResult; +use codex_db::entities::tasks; +use codex_events::EventBroadcaster; pub mod analyze_book; pub mod analyze_series; diff --git a/src/tasks/handlers/plugin_auto_match.rs b/crates/codex-tasks/src/handlers/plugin_auto_match.rs similarity index 98% rename from src/tasks/handlers/plugin_auto_match.rs rename to crates/codex-tasks/src/handlers/plugin_auto_match.rs index 190d555b..b6b43e1e 100644 --- a/src/tasks/handlers/plugin_auto_match.rs +++ b/crates/codex-tasks/src/handlers/plugin_auto_match.rs @@ -19,27 +19,25 @@ use std::sync::Arc; use tracing::{debug, error, info, warn}; use uuid::Uuid; -use crate::db::entities::tasks; -use crate::db::repositories::{ +use crate::handlers::TaskHandler; +use crate::types::TaskResult; +use codex_db::entities::tasks; +use codex_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::{ +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 crate::services::metadata::{ +use codex_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_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"; @@ -128,7 +126,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> { @@ -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/crates/codex-tasks/src/handlers/poll_release_source.rs similarity index 94% rename from src/tasks/handlers/poll_release_source.rs rename to crates/codex-tasks/src/handlers/poll_release_source.rs index 52f1e132..dd1d945c 100644 --- a/src/tasks/handlers/poll_release_source.rs +++ b/crates/codex-tasks/src/handlers/poll_release_source.rs @@ -33,23 +33,23 @@ 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::{ +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; +use codex_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; -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_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 @@ -633,35 +635,22 @@ 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, ) { 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(), )); } -/// 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`). @@ -672,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()); @@ -698,7 +687,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()) @@ -713,7 +702,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, ) { @@ -740,15 +729,15 @@ 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 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. @@ -757,7 +746,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(), @@ -817,7 +806,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(), @@ -862,8 +851,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/crates/codex-tasks/src/handlers/purge_deleted.rs similarity index 90% rename from src/tasks/handlers/purge_deleted.rs rename to crates/codex-tasks/src/handlers/purge_deleted.rs index 4363ae4c..4120660d 100644 --- a/src/tasks/handlers/purge_deleted.rs +++ b/crates/codex-tasks/src/handlers/purge_deleted.rs @@ -4,11 +4,11 @@ use serde_json::json; use std::sync::Arc; 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 crate::handlers::TaskHandler; +use crate::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/crates/codex-tasks/src/handlers/refresh_library_metadata.rs similarity index 95% rename from src/tasks/handlers/refresh_library_metadata.rs rename to crates/codex-tasks/src/handlers/refresh_library_metadata.rs index 36eee0e2..84dc3f29 100644 --- a/src/tasks/handlers/refresh_library_metadata.rs +++ b/crates/codex-tasks/src/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,23 +20,23 @@ use std::sync::Arc; use std::time::Duration; use tracing::{debug, error, info, warn}; -use crate::db::entities::tasks; -use crate::db::repositories::{ +use crate::handlers::TaskHandler; +use crate::types::TaskResult; +use codex_db::entities::tasks; +use codex_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::{ +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 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_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; @@ -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>>, @@ -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::{ + use crate::types::TaskType; + use codex_db::ScanningStrategy; + use codex_db::entities::plugins::PluginPermission; + use codex_db::repositories::{ CreateLibraryJobParams, LibraryJobRepository, LibraryRepository, PluginsRepository, SeriesRepository, TaskRepository, }; - use crate::db::test_helpers::setup_test_db; - use crate::services::library_jobs::{ + use codex_db::test_helpers::setup_test_db; + use codex_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_services::plugin::PluginManager; + use codex_services::plugin::protocol::PluginScope; use std::env; use std::sync::Once; diff --git a/src/tasks/handlers/renumber_series.rs b/crates/codex-tasks/src/handlers/renumber_series.rs similarity index 95% rename from src/tasks/handlers/renumber_series.rs rename to crates/codex-tasks/src/handlers/renumber_series.rs index 07bf722b..38191dfa 100644 --- a/src/tasks/handlers/renumber_series.rs +++ b/crates/codex-tasks/src/handlers/renumber_series.rs @@ -11,11 +11,11 @@ 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::events::{EntityChangeEvent, EntityEvent, EventBroadcaster, TaskProgressEvent}; -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}; // ============================================================================= // RenumberSeries Handler (Single Series) @@ -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/reprocess_series_titles.rs b/crates/codex-tasks/src/handlers/reprocess_series_titles.rs similarity index 97% rename from src/tasks/handlers/reprocess_series_titles.rs rename to crates/codex-tasks/src/handlers/reprocess_series_titles.rs index 70915460..e1d913ab 100644 --- a/src/tasks/handlers/reprocess_series_titles.rs +++ b/crates/codex-tasks/src/handlers/reprocess_series_titles.rs @@ -10,14 +10,14 @@ use std::sync::Arc; use tracing::{debug, info, warn}; use uuid::Uuid; -use crate::db::entities::{series_metadata, tasks}; -use crate::db::repositories::{ +use crate::handlers::TaskHandler; +use crate::types::{TaskResult, TaskType}; +use codex_db::entities::{series_metadata, tasks}; +use codex_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}; +use codex_services::metadata::preprocessing::apply_rules; // ============================================================================= // ReprocessSeriesTitle Handler (Single Series) diff --git a/src/tasks/handlers/scan_library.rs b/crates/codex-tasks/src/handlers/scan_library.rs similarity index 96% rename from src/tasks/handlers/scan_library.rs rename to crates/codex-tasks/src/handlers/scan_library.rs index ab3a8d08..09bbd07a 100644 --- a/src/tasks/handlers/scan_library.rs +++ b/crates/codex-tasks/src/handlers/scan_library.rs @@ -4,16 +4,16 @@ use serde_json::json; use std::sync::Arc; use tracing::{debug, error, info, warn}; -use crate::db::entities::tasks; -use crate::db::repositories::{ +use crate::handlers::TaskHandler; +use crate::types::{TaskResult, TaskType}; +use codex_db::entities::tasks; +use codex_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; +use codex_scanner::{ScanMode, ScanningConfig, scan_library}; +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/crates/codex-tasks/src/handlers/user_plugin_recommendation_dismiss.rs similarity index 94% rename from src/tasks/handlers/user_plugin_recommendation_dismiss.rs rename to crates/codex-tasks/src/handlers/user_plugin_recommendation_dismiss.rs index 9829204b..b00440b3 100644 --- a/src/tasks/handlers/user_plugin_recommendation_dismiss.rs +++ b/crates/codex-tasks/src/handlers/user_plugin_recommendation_dismiss.rs @@ -11,16 +11,16 @@ use std::time::Duration; 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; -use crate::services::plugin::recommendations::{ +use crate::handlers::TaskHandler; +use crate::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, }; -use crate::tasks::handlers::TaskHandler; -use crate::tasks::types::TaskResult; /// 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/crates/codex-tasks/src/handlers/user_plugin_recommendations.rs similarity index 96% rename from src/tasks/handlers/user_plugin_recommendations.rs rename to crates/codex-tasks/src/handlers/user_plugin_recommendations.rs index 48d12ee6..5cf20c07 100644 --- a/src/tasks/handlers/user_plugin_recommendations.rs +++ b/crates/codex-tasks/src/handlers/user_plugin_recommendations.rs @@ -14,20 +14,20 @@ 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::events::EventBroadcaster; -use crate::services::SettingsService; -use crate::services::plugin::PluginManager; -use crate::services::plugin::library::build_user_library; -use crate::services::plugin::protocol::{ +use crate::handlers::TaskHandler; +use crate::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 crate::services::plugin::recommendations::{ +use codex_services::plugin::recommendations::{ RecommendationClearResponse, RecommendationRequest, RecommendationResponse, }; -use crate::tasks::handlers::TaskHandler; -use crate::tasks::types::TaskResult; /// 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/crates/codex-tasks/src/handlers/user_plugin_sync/mod.rs similarity index 96% rename from src/tasks/handlers/user_plugin_sync/mod.rs rename to crates/codex-tasks/src/handlers/user_plugin_sync/mod.rs index fd0b9916..0815b97f 100644 --- a/src/tasks/handlers/user_plugin_sync/mod.rs +++ b/crates/codex-tasks/src/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::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; -use crate::services::plugin::sync::{ +use crate::handlers::TaskHandler; +use crate::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, }; -use crate::tasks::handlers::TaskHandler; -use crate::tasks::types::TaskResult; 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/crates/codex-tasks/src/handlers/user_plugin_sync/pull.rs similarity index 95% rename from src/tasks/handlers/user_plugin_sync/pull.rs rename to crates/codex-tasks/src/handlers/user_plugin_sync/pull.rs index 36bbe796..e3826010 100644 --- a/src/tasks/handlers/user_plugin_sync/pull.rs +++ b/crates/codex-tasks/src/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 codex_db::repositories::{ BookRepository, ReadProgressRepository, SeriesExternalIdRepository, UserSeriesRatingRepository, }; -use crate::services::plugin::sync::{SyncEntry, SyncReadingStatus}; +use codex_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/crates/codex-tasks/src/handlers/user_plugin_sync/push.rs similarity index 98% rename from src/tasks/handlers/user_plugin_sync/push.rs rename to crates/codex-tasks/src/handlers/user_plugin_sync/push.rs index a37c6f80..71d8f7f2 100644 --- a/src/tasks/handlers/user_plugin_sync/push.rs +++ b/crates/codex-tasks/src/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 codex_db::repositories::{ BookRepository, ReadProgressRepository, SeriesExternalIdRepository, SeriesMetadataRepository, UserSeriesRatingRepository, }; -use crate::services::plugin::sync::{SyncEntry, SyncProgress, SyncReadingStatus}; +use codex_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/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 99% rename from src/tasks/handlers/user_plugin_sync/tests.rs rename to crates/codex-tasks/src/handlers/user_plugin_sync/tests.rs index c605e001..340279b5 100644 --- a/src/tasks/handlers/user_plugin_sync/tests.rs +++ b/crates/codex-tasks/src/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 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; +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/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/crates/codex-tasks/src/types.rs b/crates/codex-tasks/src/types.rs new file mode 100644 index 00000000..fdd38d75 --- /dev/null +++ b/crates/codex-tasks/src/types.rs @@ -0,0 +1,8 @@ +//! Re-export of task value types. +//! +//! The canonical home is [`codex_models::task`]. This module keeps the +//! `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. + +pub use codex_models::task::*; diff --git a/src/tasks/worker.rs b/crates/codex-tasks/src/worker.rs similarity index 96% rename from src/tasks/worker.rs rename to crates/codex-tasks/src/worker.rs index f31cfd82..c2dd679a 100644 --- a/src/tasks/worker.rs +++ b/crates/codex-tasks/src/worker.rs @@ -16,16 +16,8 @@ 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; -use crate::services::user_plugin::OAuthStateManager; -use crate::services::{SettingsService, TaskMetricsService, ThumbnailService}; -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, @@ -38,6 +30,14 @@ use crate::tasks::handlers::{ UserPluginRecommendationDismissHandler, UserPluginRecommendationsHandler, UserPluginSyncHandler, }; +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- @@ -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(); } } @@ -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 @@ -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()), ), @@ -807,8 +807,8 @@ impl TaskWorker { /// Complete a task successfully, storing result and recorded events async fn complete_task( &self, - task: &crate::db::entities::tasks::Model, - task_result: crate::tasks::types::TaskResult, + task: &codex_db::entities::tasks::Model, + task_result: crate::types::TaskResult, started_at: chrono::DateTime<Utc>, recorded_events: Option<Vec<RecordedEvent>>, ) -> Result<()> { @@ -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,11 +1015,11 @@ impl TaskWorker { #[cfg(test)] 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 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}; /// Stub handler that returns whatever `TaskResult` it was constructed with. /// Used to drive the worker through specific result branches without @@ -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>> diff --git a/crates/codex-utils/Cargo.toml b/crates/codex-utils/Cargo.toml new file mode 100644 index 00000000..57128230 --- /dev/null +++ b/crates/codex-utils/Cargo.toml @@ -0,0 +1,34 @@ +[package] +name = "codex-utils" +version.workspace = true +edition.workspace = true +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" +jsonwebtoken = { version = "10", features = ["aws_lc_rs"] } +md-5 = "0.10" +rand = "0.10" +serde_json = "1.0" +sha2 = "0.10" +unicode-normalization = "0.1" + +[dev-dependencies] +tempfile = { workspace = true } +serial_test = { workspace = true } diff --git a/src/services/plugin/encryption.rs b/crates/codex-utils/src/credential_encryption.rs similarity index 100% rename from src/services/plugin/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 98% rename from src/utils/deadline.rs rename to crates/codex-utils/src/deadline.rs index 2197bf08..64ca031f 100644 --- a/src/utils/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/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 e3ee2d1e..24450f25 100644 --- a/src/utils/jwt.rs +++ b/crates/codex-utils/src/jwt.rs @@ -4,9 +4,9 @@ #![allow(dead_code)] -use crate::api::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 67% rename from src/utils/mod.rs rename to crates/codex-utils/src/lib.rs index 1f6f7f49..ad9759d4 100644 --- a/src/utils/mod.rs +++ b/crates/codex-utils/src/lib.rs @@ -1,6 +1,12 @@ +//! Codex utility helpers shared across the workspace. +//! +//! 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; @@ -11,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/src/utils/natural_sort.rs b/crates/codex-utils/src/natural_sort.rs similarity index 99% rename from src/utils/natural_sort.rs rename to crates/codex-utils/src/natural_sort.rs index 19efc7a4..9979fa79 100644 --- a/src/utils/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/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/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/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/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", + ], + }, ], }; 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] diff --git a/src/commands/migrate.rs b/src/commands/migrate.rs index e29d3104..4cd39389 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_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/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/scan.rs b/src/commands/scan.rs index 338e8aa9..6c5d1d6e 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 codex_scanner::{analyze_file, detect_format}; use std::path::PathBuf; use tabled::{Table, Tabled}; diff --git a/src/commands/seed.rs b/src/commands/seed.rs index e9ac885f..5157ce45 100644 --- a/src/commands/seed.rs +++ b/src/commands/seed.rs @@ -1,18 +1,18 @@ -use crate::api::permissions::{ +use anyhow::{Context, Result}; +use chrono::Utc; +use codex_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::{ +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 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_models::{BookStrategy, NumberStrategy, SeriesStrategy}; +use codex_services::plugin::protocol::PluginScope; +use codex_utils::password::hash_password; use rand::RngExt; use serde::Deserialize; use std::collections::HashMap; @@ -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 85139794..98587990 100644 --- a/src/commands/serve.rs +++ b/src/commands/serve.rs @@ -1,9 +1,9 @@ -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, }; -use crate::config::DatabaseType; +use codex_config::DatabaseType; use std::path::PathBuf; use std::sync::Arc; use tokio::signal; @@ -57,11 +57,11 @@ 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 - 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 @@ -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, ) @@ -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(), )); @@ -140,14 +140,14 @@ 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(), ); // 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"); @@ -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"); } @@ -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, )); @@ -337,15 +337,20 @@ 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. + let scheduler_handle: codex_services::scheduler_handle::SharedSchedulerReconciler = Arc::new( + codex_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()) .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 { @@ -357,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(); @@ -390,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 @@ -427,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()) } }; @@ -442,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(), @@ -450,13 +455,13 @@ 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, )); - 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(crate::utils::jwt::JwtService::new( + jwt_service: Arc::new(codex_utils::jwt::JwtService::new( config.auth.jwt_secret.clone(), config.auth.jwt_expiry_hours, )), @@ -480,8 +485,8 @@ 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()), - user_auth_cache: Arc::new(crate::api::extractors::auth::UserAuthCache::new()), + inflight_thumbnails: Arc::new(codex_services::InflightThumbnailTracker::new()), + user_auth_cache: Arc::new(codex_api::extractors::auth::UserAuthCache::new()), rate_limiter_service, plugin_manager: plugin_manager.clone(), plugin_metrics_service, @@ -491,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 @@ -506,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/commands/tasks.rs b/src/commands/tasks.rs index 989fb2da..f4ed8b53 100644 --- a/src/commands/tasks.rs +++ b/src/commands/tasks.rs @@ -5,10 +5,10 @@ use sea_orm::{ColumnTrait, DatabaseConnection, EntityTrait, PaginatorTrait, Quer 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_cli_common::{init_database, load_config}; +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/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 8ea3126b..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, @@ -55,18 +55,18 @@ 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 - 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, @@ -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"); } @@ -125,17 +125,17 @@ 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 // // 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( + 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/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..3c46160d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,14 +1,16 @@ -pub mod api; -pub mod config; -pub mod db; -pub mod events; -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 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; +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; +pub use codex_utils as utils; diff --git a/src/main.rs b/src/main.rs index 2064e1ea..3e011479 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,18 +1,4 @@ -mod api; mod commands; -mod config; -mod db; -mod events; -mod models; -mod observability; -mod parsers; -mod scanner; -mod scheduler; -mod search; -mod services; -mod tasks; -mod utils; -mod web; use clap::{Parser, Subcommand}; use commands::{ diff --git a/src/parsers/mod.rs b/src/parsers/mod.rs deleted file mode 100644 index 73fb9a65..00000000 --- a/src/parsers/mod.rs +++ /dev/null @@ -1,15 +0,0 @@ -#[cfg(feature = "rar")] -pub mod cbr; -pub mod cbz; -pub mod comic_info; -pub mod epub; -pub mod image_utils; -pub mod isbn_utils; -pub mod metadata; -pub mod opf; -pub mod pdf; -pub mod series_json; -pub mod traits; - -pub use comic_info::parse_comic_info; -pub use metadata::*; diff --git a/src/utils/error.rs b/src/utils/error.rs deleted file mode 100644 index 77d5ad96..00000000 --- a/src/utils/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/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)