diff --git a/Cargo.lock b/Cargo.lock index 26990f65..26d26bc0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -818,6 +818,7 @@ dependencies = [ "lettre", "log", "lopdf", + "md-5", "migration", "mime_guess", "openidconnect", diff --git a/Cargo.toml b/Cargo.toml index 79a984e2..558b468c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -47,6 +47,7 @@ infer = "0.19" # Hashing sha2 = "0.10" +md-5 = "0.10" # Error handling anyhow = "1.0" diff --git a/migration/src/lib.rs b/migration/src/lib.rs index 73b65d3d..161b6820 100644 --- a/migration/src/lib.rs +++ b/migration/src/lib.rs @@ -120,6 +120,9 @@ mod m20260220_000058_remove_prioritize_scans_setting; // Add search_title column for accent-insensitive search mod m20260222_000059_add_search_title; +// Add koreader_hash column for KOReader sync +mod m20260309_000060_add_koreader_hash; + pub struct Migrator; #[async_trait::async_trait] @@ -214,6 +217,8 @@ impl MigratorTrait for Migrator { Box::new(m20260220_000058_remove_prioritize_scans_setting::Migration), // Add search_title for accent-insensitive search Box::new(m20260222_000059_add_search_title::Migration), + // Add koreader_hash for KOReader sync + Box::new(m20260309_000060_add_koreader_hash::Migration), ] } } diff --git a/migration/src/m20260309_000060_add_koreader_hash.rs b/migration/src/m20260309_000060_add_koreader_hash.rs new file mode 100644 index 00000000..2b80971c --- /dev/null +++ b/migration/src/m20260309_000060_add_koreader_hash.rs @@ -0,0 +1,59 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + // Add koreader_hash column to books table (nullable, computed on demand) + manager + .alter_table( + Table::alter() + .table(Books::Table) + .add_column(ColumnDef::new(Alias::new("koreader_hash")).string().null()) + .to_owned(), + ) + .await?; + + // Add index for fast lookup by koreader_hash + manager + .create_index( + Index::create() + .name("idx_books_koreader_hash") + .table(Books::Table) + .col(Alias::new("koreader_hash")) + .to_owned(), + ) + .await?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_index( + Index::drop() + .name("idx_books_koreader_hash") + .table(Books::Table) + .to_owned(), + ) + .await?; + + manager + .alter_table( + Table::alter() + .table(Books::Table) + .drop_column(Alias::new("koreader_hash")) + .to_owned(), + ) + .await?; + + Ok(()) + } +} + +#[derive(DeriveIden)] +enum Books { + Table, +} diff --git a/src/api/routes/koreader/dto/mod.rs b/src/api/routes/koreader/dto/mod.rs new file mode 100644 index 00000000..eaa01bba --- /dev/null +++ b/src/api/routes/koreader/dto/mod.rs @@ -0,0 +1 @@ +pub mod progress; diff --git a/src/api/routes/koreader/dto/progress.rs b/src/api/routes/koreader/dto/progress.rs new file mode 100644 index 00000000..60c4cdb3 --- /dev/null +++ b/src/api/routes/koreader/dto/progress.rs @@ -0,0 +1,29 @@ +use serde::{Deserialize, Serialize}; + +/// KOReader document progress DTO +/// +/// Used for both request and response when syncing reading progress. +/// Field names use snake_case to match KOReader's expected JSON format. +#[derive(Debug, Serialize, Deserialize)] +pub struct DocumentProgressDto { + /// KOReader partial MD5 hash identifying the document + pub document: String, + + /// Reading progress as a string (page number for PDF/CBZ, XPath for EPUB) + pub progress: String, + + /// Overall progress percentage (0.0 to 1.0) + pub percentage: f64, + + /// Device name + pub device: String, + + /// Device identifier + pub device_id: String, +} + +/// Response for successful authentication +#[derive(Debug, Serialize, Deserialize)] +pub struct AuthorizedDto { + pub authorized: String, +} diff --git a/src/api/routes/koreader/handlers/auth.rs b/src/api/routes/koreader/handlers/auth.rs new file mode 100644 index 00000000..9f5bec95 --- /dev/null +++ b/src/api/routes/koreader/handlers/auth.rs @@ -0,0 +1,25 @@ +//! KOReader authentication handlers + +use crate::api::error::ApiError; +use crate::api::extractors::AuthContext; +use crate::api::routes::koreader::dto::progress::AuthorizedDto; +use axum::Json; +use axum::http::StatusCode; + +/// POST /koreader/users/create +/// +/// Always returns 403 Forbidden. User registration is handled by Codex itself, +/// not through the KOReader sync protocol. +pub async fn create_user() -> StatusCode { + StatusCode::FORBIDDEN +} + +/// GET /koreader/users/auth +/// +/// Returns 200 with `{"authorized": "OK"}` if the user is authenticated. +/// KOReader uses x-auth-user/x-auth-key headers, which map to Basic Auth in Codex. +pub async fn authorize(_auth: AuthContext) -> Result, ApiError> { + Ok(Json(AuthorizedDto { + authorized: "OK".to_string(), + })) +} diff --git a/src/api/routes/koreader/handlers/mod.rs b/src/api/routes/koreader/handlers/mod.rs new file mode 100644 index 00000000..e4fee8e9 --- /dev/null +++ b/src/api/routes/koreader/handlers/mod.rs @@ -0,0 +1,2 @@ +pub mod auth; +pub mod sync; diff --git a/src/api/routes/koreader/handlers/sync.rs b/src/api/routes/koreader/handlers/sync.rs new file mode 100644 index 00000000..422f9e97 --- /dev/null +++ b/src/api/routes/koreader/handlers/sync.rs @@ -0,0 +1,205 @@ +//! KOReader sync progress handlers + +use crate::api::error::ApiError; +use crate::api::extractors::{AuthContext, AuthState}; +use crate::api::routes::koreader::dto::progress::DocumentProgressDto; +use crate::db::repositories::{BookRepository, ReadProgressRepository}; +use axum::Json; +use axum::extract::{Path, State}; +use std::sync::Arc; + +/// GET /koreader/syncs/progress/{document} +/// +/// Get reading progress for a document identified by its KOReader hash. +/// Returns the stored progress if found. +pub async fn get_progress( + State(state): State>, + auth: AuthContext, + Path(document_hash): Path, +) -> Result, ApiError> { + let user_id = auth.user_id; + + // Find book by koreader_hash + let books = BookRepository::find_by_koreader_hash(&state.db, &document_hash) + .await + .map_err(|e| ApiError::Internal(format!("Failed to find book: {}", e)))?; + + if books.is_empty() { + return Err(ApiError::NotFound( + "No book found with this hash".to_string(), + )); + } + + if books.len() > 1 { + return Err(ApiError::Conflict( + "Multiple books found with the same hash".to_string(), + )); + } + + let book = &books[0]; + + // Get reading progress for this user and book + let progress = ReadProgressRepository::get_by_user_and_book(&state.db, user_id, book.id) + .await + .map_err(|e| ApiError::Internal(format!("Failed to get progress: {}", e)))?; + + match progress { + Some(p) => { + // Convert internal progress to KOReader format + // For PDF/CBZ: progress is the page number as a string + // For EPUB: we store page number but KOReader expects DocFragment format + let progress_str = p.current_page.to_string(); + let percentage = p + .progress_percentage + .unwrap_or_else(|| p.current_page as f64 / book.page_count.max(1) as f64); + + Ok(Json(DocumentProgressDto { + document: document_hash, + progress: progress_str, + percentage, + device: String::new(), + device_id: String::new(), + })) + } + None => Err(ApiError::NotFound( + "No progress found for this book".to_string(), + )), + } +} + +/// PUT /koreader/syncs/progress +/// +/// Update reading progress for a document identified by its KOReader hash. +pub async fn update_progress( + State(state): State>, + auth: AuthContext, + Json(request): Json, +) -> Result, ApiError> { + let user_id = auth.user_id; + + // Find book by koreader_hash + let books = BookRepository::find_by_koreader_hash(&state.db, &request.document) + .await + .map_err(|e| ApiError::Internal(format!("Failed to find book: {}", e)))?; + + if books.is_empty() { + return Err(ApiError::NotFound( + "No book found with this hash".to_string(), + )); + } + + if books.len() > 1 { + return Err(ApiError::Conflict( + "Multiple books found with the same hash".to_string(), + )); + } + + let book = &books[0]; + + // Parse progress string to page number + // For PDF/CBZ: progress is the page number as a string + // For EPUB: progress is a DocFragment XPath string, extract the index + let current_page = parse_koreader_progress(&request.progress, &book.format); + + let completed = + request.percentage >= 0.98 || (book.page_count > 0 && current_page >= book.page_count); + + // Update progress + ReadProgressRepository::upsert_with_percentage( + &state.db, + user_id, + book.id, + current_page, + Some(request.percentage), + completed, + ) + .await + .map_err(|e| ApiError::Internal(format!("Failed to update progress: {}", e)))?; + + Ok(Json(request)) +} + +/// Parse KOReader progress string into a page number +/// +/// For PDF/CBZ (pre-paginated): progress is just a page number string like "42" +/// For EPUB: progress is a DocFragment XPath like "/body/DocFragment[10]/body/div/p[1]/text().0" +/// or a TOC-based format like "#_doc_fragment_44_ c37" +fn parse_koreader_progress(progress: &str, format: &str) -> i32 { + match format { + "epub" => parse_epub_progress(progress), + _ => { + // PDF, CBZ, CBR: progress is a page number + progress.parse::().unwrap_or(1).max(1) + } + } +} + +/// Parse EPUB progress from KOReader format +/// +/// Handles two formats: +/// 1. DocFragment[N] (1-based): "/body/DocFragment[10]/body/div/p[1]/text().0" +/// 2. _doc_fragment_N_ (0-based): "#_doc_fragment_44_ c37" +/// 3. Plain number fallback +fn parse_epub_progress(progress: &str) -> i32 { + // Try DocFragment[N] format (1-based index) + if let Some(start) = progress.find("DocFragment[") { + let after = &progress[start + 12..]; + if let Some(end) = after.find(']') + && let Ok(index) = after[..end].parse::() + { + return index.max(1); + } + } + + // Try _doc_fragment_N_ format (0-based index) + if let Some(start) = progress.find("_doc_fragment_") { + let after = &progress[start + 14..]; + if let Some(end) = after.find('_') + && let Ok(index) = after[..end].parse::() + { + return (index + 1).max(1); // Convert 0-based to 1-based + } + } + + // Fallback: try parsing as plain number + progress.parse::().unwrap_or(1).max(1) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_pdf_progress() { + assert_eq!(parse_koreader_progress("42", "cbz"), 42); + assert_eq!(parse_koreader_progress("1", "pdf"), 1); + assert_eq!(parse_koreader_progress("0", "cbr"), 1); // min 1 + assert_eq!(parse_koreader_progress("invalid", "pdf"), 1); + } + + #[test] + fn test_parse_epub_doc_fragment() { + assert_eq!( + parse_koreader_progress("/body/DocFragment[10]/body/div/p[1]/text().0", "epub"), + 10 + ); + assert_eq!(parse_koreader_progress("/body/DocFragment[1].0", "epub"), 1); + } + + #[test] + fn test_parse_epub_doc_fragment_underscore() { + assert_eq!( + parse_koreader_progress("#_doc_fragment_44_ c37", "epub"), + 45 // 0-based 44 -> 1-based 45 + ); + assert_eq!( + parse_koreader_progress("#_doc_fragment_0_ c0", "epub"), + 1 // 0-based 0 -> 1-based 1 + ); + } + + #[test] + fn test_parse_epub_plain_number() { + assert_eq!(parse_koreader_progress("5", "epub"), 5); + } +} diff --git a/src/api/routes/koreader/mod.rs b/src/api/routes/koreader/mod.rs new file mode 100644 index 00000000..98d21fb1 --- /dev/null +++ b/src/api/routes/koreader/mod.rs @@ -0,0 +1,43 @@ +//! KOReader sync API module +//! +//! This module provides the KOReader sync API, allowing KOReader e-readers +//! to sync reading progress with Codex. +//! +//! ## Configuration +//! +//! The KOReader API is disabled by default. Enable it via configuration: +//! +//! ```yaml +//! koreader_api: +//! enabled: true +//! ``` +//! +//! Or via environment variable: +//! +//! ```bash +//! CODEX_KOREADER_API_ENABLED=true +//! ``` +//! +//! ## Endpoints +//! +//! When enabled, the following endpoints are available at `/koreader/`: +//! +//! - `POST /users/create` - Always returns 403 (registration handled by Codex) +//! - `GET /users/auth` - Verify authentication +//! - `GET /syncs/progress/{document}` - Get reading progress by KOReader hash +//! - `PUT /syncs/progress` - Update reading progress + +pub mod dto; +pub mod handlers; +pub mod routes; + +use crate::api::extractors::AppState; +use axum::Router; +use std::sync::Arc; + +/// Create the KOReader sync API router +/// +/// This router is mounted at `/koreader` when the KOReader API is enabled. +pub fn router(state: Arc) -> Router { + routes::create_router(state) +} diff --git a/src/api/routes/koreader/routes/mod.rs b/src/api/routes/koreader/routes/mod.rs new file mode 100644 index 00000000..b1b92d83 --- /dev/null +++ b/src/api/routes/koreader/routes/mod.rs @@ -0,0 +1,32 @@ +//! KOReader sync API route definitions + +use crate::api::extractors::AppState; +use crate::api::routes::koreader::handlers; +use axum::{ + Router, + routing::{get, post, put}, +}; +use std::sync::Arc; + +/// Create the KOReader sync API router +/// +/// Mounted at `/koreader` when enabled. +/// +/// Routes: +/// - `POST /users/create` - Always 403 (registration via Codex) +/// - `GET /users/auth` - Verify authentication +/// - `GET /syncs/progress/:document` - Get progress by KOReader hash +/// - `PUT /syncs/progress` - Update progress +pub fn create_router(state: Arc) -> Router { + Router::new() + // User endpoints + .route("/users/create", post(handlers::auth::create_user)) + .route("/users/auth", get(handlers::auth::authorize)) + // Sync endpoints + .route( + "/syncs/progress/{document}", + get(handlers::sync::get_progress), + ) + .route("/syncs/progress", put(handlers::sync::update_progress)) + .with_state(state) +} diff --git a/src/api/routes/mod.rs b/src/api/routes/mod.rs index 81e35d88..a4a9fc92 100644 --- a/src/api/routes/mod.rs +++ b/src/api/routes/mod.rs @@ -1,4 +1,5 @@ pub mod komga; +pub mod koreader; pub mod opds; pub mod opds2; pub mod v1; @@ -50,6 +51,12 @@ pub fn create_router(state: Arc, config: &Config) -> Router { router = router.nest(&komga_path, komga::router(state.clone())); } + // Conditionally mount KOReader sync API if enabled + if config.koreader_api.enabled { + tracing::info!("KOReader sync API enabled at /koreader"); + router = router.nest("/koreader", koreader::router(state.clone())); + } + // Conditionally mount Scalar API docs if enabled if config.api.enable_api_docs { tracing::info!("API docs (Scalar) enabled at {}", config.api.api_docs_path); diff --git a/src/config/env_override.rs b/src/config/env_override.rs index 7f8e3cc6..7a7771b1 100644 --- a/src/config/env_override.rs +++ b/src/config/env_override.rs @@ -1,7 +1,8 @@ +#[allow(unused_imports)] use super::types::{ ApiConfig, ApplicationConfig, AuthConfig, Config, DatabaseConfig, DatabaseType, FilesConfig, - KomgaApiConfig, LogLevel, LoggingConfig, OidcConfig, OidcDefaultRole, OidcProviderConfig, - PostgresConfig, RateLimitConfig, SQLiteConfig, ScannerConfig, TaskConfig, + KomgaApiConfig, KoreaderApiConfig, LogLevel, LoggingConfig, OidcConfig, OidcDefaultRole, + OidcProviderConfig, PostgresConfig, RateLimitConfig, SQLiteConfig, ScannerConfig, TaskConfig, }; use std::collections::HashMap; use std::env; @@ -666,6 +667,7 @@ mod tests { files: FilesConfig::default(), pdf: PdfConfig::default(), komga_api: KomgaApiConfig::default(), + koreader_api: KoreaderApiConfig::default(), rate_limit: RateLimitConfig::default(), }; @@ -855,6 +857,7 @@ mod tests { enabled: false, prefix: "default".to_string(), }, + koreader_api: KoreaderApiConfig::default(), rate_limit: RateLimitConfig::default(), }; diff --git a/src/config/loader.rs b/src/config/loader.rs index 556e26d2..3306f817 100644 --- a/src/config/loader.rs +++ b/src/config/loader.rs @@ -22,8 +22,8 @@ mod tests { use super::*; use crate::config::{ ApiConfig, ApplicationConfig, AuthConfig, DatabaseConfig, DatabaseType, EmailConfig, - FilesConfig, KomgaApiConfig, LoggingConfig, PdfConfig, RateLimitConfig, SQLiteConfig, - ScannerConfig, SchedulerConfig, TaskConfig, + FilesConfig, KomgaApiConfig, KoreaderApiConfig, LoggingConfig, PdfConfig, RateLimitConfig, + SQLiteConfig, ScannerConfig, SchedulerConfig, TaskConfig, }; use tempfile::NamedTempFile; @@ -78,6 +78,7 @@ application: files: FilesConfig::default(), pdf: PdfConfig::default(), komga_api: KomgaApiConfig::default(), + koreader_api: KoreaderApiConfig::default(), rate_limit: RateLimitConfig::default(), }; @@ -163,6 +164,7 @@ scanner: files: FilesConfig::default(), pdf: PdfConfig::default(), komga_api: KomgaApiConfig::default(), + koreader_api: KoreaderApiConfig::default(), rate_limit: RateLimitConfig::default(), }; diff --git a/src/config/mod.rs b/src/config/mod.rs index 749194c3..cc3c1e2e 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -6,9 +6,9 @@ mod types; #[allow(unused_imports)] pub use types::{ ApiConfig, ApplicationConfig, AuthConfig, Config, DatabaseConfig, DatabaseType, EmailConfig, - FilesConfig, KomgaApiConfig, LoggingConfig, OidcConfig, OidcDefaultRole, OidcProviderConfig, - PdfConfig, PostgresConfig, RateLimitConfig, SQLiteConfig, ScannerConfig, SchedulerConfig, - TaskConfig, + FilesConfig, KomgaApiConfig, KoreaderApiConfig, LoggingConfig, OidcConfig, OidcDefaultRole, + OidcProviderConfig, PdfConfig, PostgresConfig, RateLimitConfig, SQLiteConfig, ScannerConfig, + SchedulerConfig, TaskConfig, }; pub use env_override::EnvOverride; diff --git a/src/config/types.rs b/src/config/types.rs index 19d1c79b..0ad6d65a 100644 --- a/src/config/types.rs +++ b/src/config/types.rs @@ -30,6 +30,24 @@ fn default_komga_prefix() -> String { "komga".to_string() } +/// Configuration for the KOReader sync API +/// Enables KOReader e-readers to sync reading progress with Codex +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(default)] +pub struct KoreaderApiConfig { + /// Enable KOReader sync API endpoints + /// When enabled, routes are mounted at /koreader/* + pub enabled: bool, +} + +impl Default for KoreaderApiConfig { + fn default() -> Self { + Self { + enabled: env_bool_or("CODEX_KOREADER_API_ENABLED", false), + } + } +} + /// Configuration for API rate limiting /// Uses token bucket algorithm with per-client tracking #[derive(Debug, Serialize, Deserialize, Clone)] @@ -246,6 +264,8 @@ pub struct Config { #[serde(default)] pub komga_api: KomgaApiConfig, #[serde(default)] + pub koreader_api: KoreaderApiConfig, + #[serde(default)] pub rate_limit: RateLimitConfig, } @@ -374,6 +394,7 @@ impl Default for Config { files: FilesConfig::default(), pdf: PdfConfig::default(), komga_api: KomgaApiConfig::default(), + koreader_api: KoreaderApiConfig::default(), rate_limit: RateLimitConfig::default(), } } @@ -1163,6 +1184,7 @@ verification_url_base: https://codex.example.com files: FilesConfig::default(), pdf: PdfConfig::default(), komga_api: KomgaApiConfig::default(), + koreader_api: KoreaderApiConfig::default(), rate_limit: RateLimitConfig::default(), }; diff --git a/src/db/entities/books.rs b/src/db/entities/books.rs index fa71c27b..9c6c7991 100644 --- a/src/db/entities/books.rs +++ b/src/db/entities/books.rs @@ -29,6 +29,8 @@ pub struct Model { pub updated_at: DateTime, pub thumbnail_path: Option, pub thumbnail_generated_at: Option>, + /// KOReader partial MD5 hash for KOReader sync progress tracking + pub koreader_hash: Option, } #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] diff --git a/src/db/repositories/book.rs b/src/db/repositories/book.rs index d377b02c..f2bfe321 100644 --- a/src/db/repositories/book.rs +++ b/src/db/repositories/book.rs @@ -398,6 +398,7 @@ impl BookRepository { updated_at: Set(book_model.updated_at), thumbnail_path: Set(book_model.thumbnail_path.clone()), thumbnail_generated_at: Set(book_model.thumbnail_generated_at), + koreader_hash: Set(book_model.koreader_hash.clone()), }; let created_book = book.insert(db).await.context("Failed to create book")?; @@ -493,6 +494,38 @@ impl BookRepository { .context("Failed to get book by hash") } + /// Find books by KOReader hash (for KOReader sync) + /// Returns all matching books (should normally be 0 or 1) + pub async fn find_by_koreader_hash( + db: &DatabaseConnection, + hash: &str, + ) -> Result> { + Books::find() + .filter(books::Column::KoreaderHash.eq(hash)) + .filter(books::Column::Deleted.eq(false)) + .all(db) + .await + .context("Failed to find books by KOReader hash") + } + + /// Update KOReader hash for a book + pub async fn update_koreader_hash( + db: &DatabaseConnection, + book_id: Uuid, + koreader_hash: &str, + ) -> Result<()> { + Books::update_many() + .col_expr( + books::Column::KoreaderHash, + sea_orm::sea_query::Expr::value(koreader_hash.to_string()), + ) + .filter(books::Column::Id.eq(book_id)) + .exec(db) + .await + .context("Failed to update KOReader hash")?; + Ok(()) + } + /// Get a book by file path and library ID pub async fn get_by_path( db: &DatabaseConnection, @@ -1450,6 +1483,7 @@ impl BookRepository { updated_at: Set(Utc::now()), thumbnail_path: Set(book_model.thumbnail_path.clone()), thumbnail_generated_at: Set(book_model.thumbnail_generated_at), + koreader_hash: Set(book_model.koreader_hash.clone()), }; active.update(db).await.context("Failed to update book")?; @@ -2166,6 +2200,7 @@ impl BookRepository { updated_at: Set(book_model.updated_at), thumbnail_path: Set(book_model.thumbnail_path.clone()), thumbnail_generated_at: Set(book_model.thumbnail_generated_at), + koreader_hash: Set(book_model.koreader_hash.clone()), }) .collect(); @@ -2227,6 +2262,7 @@ impl BookRepository { updated_at: Set(book_model.updated_at), thumbnail_path: Set(book_model.thumbnail_path.clone()), thumbnail_generated_at: Set(book_model.thumbnail_generated_at), + koreader_hash: Set(book_model.koreader_hash.clone()), }; active @@ -2454,6 +2490,7 @@ mod tests { updated_at: now, thumbnail_path: None, thumbnail_generated_at: None, + koreader_hash: None, } } diff --git a/src/db/repositories/book_covers.rs b/src/db/repositories/book_covers.rs index fba72fef..8d854c04 100644 --- a/src/db/repositories/book_covers.rs +++ b/src/db/repositories/book_covers.rs @@ -403,6 +403,7 @@ mod tests { updated_at: Utc::now(), thumbnail_path: None, thumbnail_generated_at: None, + koreader_hash: None, }; let book = BookRepository::create(db, &book_model, None).await.unwrap(); diff --git a/src/db/repositories/book_external_id.rs b/src/db/repositories/book_external_id.rs index c3304853..dca85317 100644 --- a/src/db/repositories/book_external_id.rs +++ b/src/db/repositories/book_external_id.rs @@ -370,6 +370,7 @@ mod tests { updated_at: Utc::now(), thumbnail_path: None, thumbnail_generated_at: None, + koreader_hash: None, }; let book = BookRepository::create(db, &book_model, None).await.unwrap(); diff --git a/src/db/repositories/book_external_links.rs b/src/db/repositories/book_external_links.rs index 18c425dc..d7510f28 100644 --- a/src/db/repositories/book_external_links.rs +++ b/src/db/repositories/book_external_links.rs @@ -227,6 +227,7 @@ mod tests { updated_at: Utc::now(), thumbnail_path: None, thumbnail_generated_at: None, + koreader_hash: None, }; BookRepository::create(db, &book_model, None).await.unwrap() @@ -600,6 +601,7 @@ mod tests { updated_at: Utc::now(), thumbnail_path: None, thumbnail_generated_at: None, + koreader_hash: None, }; let book2_model = books::Model { diff --git a/src/db/repositories/genre.rs b/src/db/repositories/genre.rs index 9ee2e6e5..838398f2 100644 --- a/src/db/repositories/genre.rs +++ b/src/db/repositories/genre.rs @@ -1006,6 +1006,7 @@ mod tests { updated_at: Utc::now(), thumbnail_path: None, thumbnail_generated_at: None, + koreader_hash: None, }; BookRepository::create(db.sea_orm_connection(), &book, None) diff --git a/src/db/repositories/metadata.rs b/src/db/repositories/metadata.rs index 854e3111..0a975b22 100644 --- a/src/db/repositories/metadata.rs +++ b/src/db/repositories/metadata.rs @@ -388,6 +388,7 @@ mod tests { updated_at: Utc::now(), thumbnail_path: None, thumbnail_generated_at: None, + koreader_hash: None, }; BookRepository::create(db.sea_orm_connection(), &book, None) diff --git a/src/db/repositories/metrics.rs b/src/db/repositories/metrics.rs index acf072ab..3730d5c7 100644 --- a/src/db/repositories/metrics.rs +++ b/src/db/repositories/metrics.rs @@ -370,6 +370,7 @@ mod tests { updated_at: now, thumbnail_path: None, thumbnail_generated_at: None, + koreader_hash: None, }; BookRepository::create(db.sea_orm_connection(), &book_model, None) @@ -438,6 +439,7 @@ mod tests { updated_at: now, thumbnail_path: None, thumbnail_generated_at: None, + koreader_hash: None, }; BookRepository::create(db.sea_orm_connection(), &book_model, None) @@ -509,6 +511,7 @@ mod tests { updated_at: now, thumbnail_path: None, thumbnail_generated_at: None, + koreader_hash: None, }; BookRepository::create(db.sea_orm_connection(), &book_model, None) diff --git a/src/db/repositories/page.rs b/src/db/repositories/page.rs index 55fcb23e..ba6aade9 100644 --- a/src/db/repositories/page.rs +++ b/src/db/repositories/page.rs @@ -182,6 +182,7 @@ mod tests { updated_at: Utc::now(), thumbnail_path: None, thumbnail_generated_at: None, + koreader_hash: None, }; BookRepository::create(db.sea_orm_connection(), &book, None) .await @@ -236,6 +237,7 @@ mod tests { updated_at: Utc::now(), thumbnail_path: None, thumbnail_generated_at: None, + koreader_hash: None, }; BookRepository::create(db.sea_orm_connection(), &book, None) .await @@ -293,6 +295,7 @@ mod tests { updated_at: Utc::now(), thumbnail_path: None, thumbnail_generated_at: None, + koreader_hash: None, }; BookRepository::create(db.sea_orm_connection(), &book, None) .await @@ -350,6 +353,7 @@ mod tests { updated_at: Utc::now(), thumbnail_path: None, thumbnail_generated_at: None, + koreader_hash: None, }; BookRepository::create(db.sea_orm_connection(), &book, None) .await @@ -409,6 +413,7 @@ mod tests { updated_at: Utc::now(), thumbnail_path: None, thumbnail_generated_at: None, + koreader_hash: None, }; BookRepository::create(db.sea_orm_connection(), &book, None) .await @@ -467,6 +472,7 @@ mod tests { updated_at: Utc::now(), thumbnail_path: None, thumbnail_generated_at: None, + koreader_hash: None, }; BookRepository::create(db.sea_orm_connection(), &book, None) .await @@ -530,6 +536,7 @@ mod tests { updated_at: Utc::now(), thumbnail_path: None, thumbnail_generated_at: None, + koreader_hash: None, }; BookRepository::create(db.sea_orm_connection(), &book, None) .await diff --git a/src/db/repositories/read_progress.rs b/src/db/repositories/read_progress.rs index b92d76f4..8f3e5f9a 100644 --- a/src/db/repositories/read_progress.rs +++ b/src/db/repositories/read_progress.rs @@ -349,6 +349,7 @@ mod tests { updated_at: Utc::now(), thumbnail_path: None, thumbnail_generated_at: None, + koreader_hash: None, }; BookRepository::create(db, &book, None).await.unwrap() } diff --git a/src/db/repositories/series.rs b/src/db/repositories/series.rs index 0aac6f1d..86d49eb6 100644 --- a/src/db/repositories/series.rs +++ b/src/db/repositories/series.rs @@ -2450,6 +2450,7 @@ mod tests { updated_at: Utc::now(), thumbnail_path: None, thumbnail_generated_at: None, + koreader_hash: None, }; BookRepository::create(db.sea_orm_connection(), &book, None) .await @@ -2544,6 +2545,7 @@ mod tests { updated_at: Utc::now(), thumbnail_path: None, thumbnail_generated_at: None, + koreader_hash: None, }; let book1: books::Model = BookRepository::create(db.sea_orm_connection(), &book1, None) .await @@ -2569,6 +2571,7 @@ mod tests { updated_at: Utc::now(), thumbnail_path: None, thumbnail_generated_at: None, + koreader_hash: None, }; let book2: books::Model = BookRepository::create(db.sea_orm_connection(), &book2, None) .await @@ -2594,6 +2597,7 @@ mod tests { updated_at: Utc::now(), thumbnail_path: None, thumbnail_generated_at: None, + koreader_hash: None, }; let book3: books::Model = BookRepository::create(db.sea_orm_connection(), &book3, None) .await diff --git a/src/db/repositories/tag.rs b/src/db/repositories/tag.rs index d54c9001..43f346b1 100644 --- a/src/db/repositories/tag.rs +++ b/src/db/repositories/tag.rs @@ -995,6 +995,7 @@ mod tests { updated_at: Utc::now(), thumbnail_path: None, thumbnail_generated_at: None, + koreader_hash: None, }; BookRepository::create(db.sea_orm_connection(), &book, None) diff --git a/src/scanner/library_scanner.rs b/src/scanner/library_scanner.rs index d006d197..48d1f8be 100644 --- a/src/scanner/library_scanner.rs +++ b/src/scanner/library_scanner.rs @@ -769,6 +769,7 @@ struct FileHashResult { path: PathBuf, path_str: String, partial_hash: String, + koreader_hash: Option, file_size: u64, modified_at: DateTime, format: String, @@ -780,11 +781,13 @@ struct FileHashResult { async fn hash_file_with_metadata(file_path: PathBuf) -> Result { let path_str = file_path.to_string_lossy().to_string(); - // Calculate current partial hash (blocking I/O operation - fast, only first 1MB) + // Calculate current partial hash and KOReader hash (blocking I/O) let file_path_clone = file_path.clone(); - let current_partial_hash = tokio::task::spawn_blocking(move || { - use crate::utils::hasher::hash_file_partial; - hash_file_partial(&file_path_clone) + let (current_partial_hash, koreader_hash) = tokio::task::spawn_blocking(move || { + use crate::utils::hasher::{hash_file_koreader, hash_file_partial}; + let partial = hash_file_partial(&file_path_clone)?; + let koreader = hash_file_koreader(&file_path_clone).ok(); + Ok::<_, std::io::Error>((partial, koreader)) }) .await .map_err(|e| anyhow::anyhow!("Failed to spawn hash calculation task: {}", e))??; @@ -812,6 +815,7 @@ async fn hash_file_with_metadata(file_path: PathBuf) -> Result { path: file_path, path_str, partial_hash: current_partial_hash, + koreader_hash, file_size, modified_at, format, @@ -1027,6 +1031,7 @@ async fn process_series_batched( updated_at: now, thumbnail_path: None, thumbnail_generated_at: None, + koreader_hash: file_hash.koreader_hash, }; batch.add_create(book_model, true); diff --git a/src/services/read_progress.rs b/src/services/read_progress.rs index 0887241a..bff9eda8 100644 --- a/src/services/read_progress.rs +++ b/src/services/read_progress.rs @@ -274,6 +274,7 @@ mod tests { updated_at: Utc::now(), thumbnail_path: None, thumbnail_generated_at: None, + koreader_hash: None, }; BookRepository::create(db, &book, None).await.unwrap() } diff --git a/src/tasks/handlers/user_plugin_sync/tests.rs b/src/tasks/handlers/user_plugin_sync/tests.rs index c15980ae..eb383eba 100644 --- a/src/tasks/handlers/user_plugin_sync/tests.rs +++ b/src/tasks/handlers/user_plugin_sync/tests.rs @@ -55,6 +55,7 @@ async fn create_test_book( updated_at: Utc::now(), thumbnail_path: None, thumbnail_generated_at: None, + koreader_hash: None, }; BookRepository::create(db, &book, None).await.unwrap() } diff --git a/src/utils/hasher.rs b/src/utils/hasher.rs index aecc8282..0459e514 100644 --- a/src/utils/hasher.rs +++ b/src/utils/hasher.rs @@ -1,6 +1,7 @@ +use md5::Md5; use sha2::{Digest, Sha256}; use std::fs::File; -use std::io::{self, Read}; +use std::io::{self, Read, Seek, SeekFrom}; use std::path::Path; /// Compute SHA-256 hash of byte slice @@ -57,6 +58,44 @@ pub fn hash_file_partial>(path: P) -> io::Result { Ok(format!("{:x}", hasher.finalize())) } +/// Compute KOReader-compatible partial MD5 hash of a file. +/// +/// KOReader uses a custom partial hashing algorithm that reads 1024-byte chunks +/// at exponentially increasing offsets throughout the file: +/// - For i in -1..=10: seek to (1024 << (2*i)) and read 1024 bytes +/// - Offsets: 256, 1024, 4096, 16384, 65536, ..., 1073741824 +/// +/// This produces a fast fingerprint without reading the entire file. +pub fn hash_file_koreader>(path: P) -> io::Result { + const CHUNK_SIZE: usize = 1024; + + let mut file = File::open(path)?; + let file_size = file.metadata()?.len(); + let mut hasher = Md5::new(); + let mut buffer = [0u8; CHUNK_SIZE]; + + for i in -1i32..=10 { + let offset = if i < 0 { + // For i=-1: 1024 >> 2 = 256 + (CHUNK_SIZE as u64) >> ((-i as u32) * 2) + } else { + (CHUNK_SIZE as u64) << ((i as u32) * 2) + }; + + if offset >= file_size { + break; + } + + file.seek(SeekFrom::Start(offset))?; + let bytes_to_read = CHUNK_SIZE.min((file_size - offset) as usize); + let buf = &mut buffer[..bytes_to_read]; + file.read_exact(buf)?; + hasher.update(buf); + } + + Ok(format!("{:x}", hasher.finalize())) +} + #[cfg(test)] mod tests { use super::*; @@ -213,4 +252,61 @@ mod tests { // Hashes should differ (partial only reads first 1MB) assert_ne!(partial_hash, full_hash); } + + #[test] + fn test_hash_file_koreader_small() { + let mut temp_file = NamedTempFile::new().unwrap(); + temp_file.write_all(b"Hello, World!").unwrap(); + temp_file.flush().unwrap(); + + let hash = hash_file_koreader(temp_file.path()).unwrap(); + // Should produce a valid MD5 hash (32 hex chars) + assert_eq!(hash.len(), 32); + } + + #[test] + fn test_hash_file_koreader_deterministic() { + let mut temp_file = NamedTempFile::new().unwrap(); + let data = vec![42u8; 100_000]; + temp_file.write_all(&data).unwrap(); + temp_file.flush().unwrap(); + + let hash1 = hash_file_koreader(temp_file.path()).unwrap(); + let hash2 = hash_file_koreader(temp_file.path()).unwrap(); + assert_eq!(hash1, hash2); + } + + #[test] + fn test_hash_file_koreader_large() { + let mut temp_file = NamedTempFile::new().unwrap(); + // Write 2MB of data to exercise multiple offset reads + let data = vec![99u8; 2 * 1024 * 1024]; + temp_file.write_all(&data).unwrap(); + temp_file.flush().unwrap(); + + let hash = hash_file_koreader(temp_file.path()).unwrap(); + assert_eq!(hash.len(), 32); + } + + #[test] + fn test_hash_file_koreader_different_content_different_hash() { + let mut file1 = NamedTempFile::new().unwrap(); + let mut file2 = NamedTempFile::new().unwrap(); + + file1.write_all(&vec![1u8; 10_000]).unwrap(); + file1.flush().unwrap(); + + file2.write_all(&vec![2u8; 10_000]).unwrap(); + file2.flush().unwrap(); + + let hash1 = hash_file_koreader(file1.path()).unwrap(); + let hash2 = hash_file_koreader(file2.path()).unwrap(); + assert_ne!(hash1, hash2); + } + + #[test] + fn test_hash_file_koreader_nonexistent() { + let result = hash_file_koreader("/nonexistent/path/to/file.txt"); + assert!(result.is_err()); + } } diff --git a/tests/api.rs b/tests/api.rs index de0cbf94..2a04e85c 100644 --- a/tests/api.rs +++ b/tests/api.rs @@ -20,6 +20,7 @@ mod api { mod genres; mod info; mod komga; + mod koreader; mod libraries; mod metadata_locks; mod metadata_reset; diff --git a/tests/api/books.rs b/tests/api/books.rs index 5d75802c..327bced0 100644 --- a/tests/api/books.rs +++ b/tests/api/books.rs @@ -59,6 +59,7 @@ fn create_test_book_model( updated_at: Utc::now(), thumbnail_path: None, thumbnail_generated_at: None, + koreader_hash: None, } } diff --git a/tests/api/bulk_metadata.rs b/tests/api/bulk_metadata.rs index 27dcc528..bb1eaede 100644 --- a/tests/api/bulk_metadata.rs +++ b/tests/api/bulk_metadata.rs @@ -60,6 +60,7 @@ fn create_test_book_model( updated_at: Utc::now(), thumbnail_path: None, thumbnail_generated_at: None, + koreader_hash: None, } } diff --git a/tests/api/bulk_operations.rs b/tests/api/bulk_operations.rs index 5da0fc14..c8f79341 100644 --- a/tests/api/bulk_operations.rs +++ b/tests/api/bulk_operations.rs @@ -61,6 +61,7 @@ fn create_test_book_model( updated_at: Utc::now(), thumbnail_path: None, thumbnail_generated_at: None, + koreader_hash: None, } } diff --git a/tests/api/covers.rs b/tests/api/covers.rs index 3d0e8853..fc3c093f 100644 --- a/tests/api/covers.rs +++ b/tests/api/covers.rs @@ -694,6 +694,7 @@ fn create_test_book_model( updated_at: Utc::now(), thumbnail_path: None, thumbnail_generated_at: None, + koreader_hash: None, } } diff --git a/tests/api/genres.rs b/tests/api/genres.rs index b7331997..3c55569d 100644 --- a/tests/api/genres.rs +++ b/tests/api/genres.rs @@ -600,6 +600,7 @@ fn create_test_book_model( updated_at: Utc::now(), thumbnail_path: None, thumbnail_generated_at: None, + koreader_hash: None, } } diff --git a/tests/api/koreader.rs b/tests/api/koreader.rs new file mode 100644 index 00000000..4e1c6ccf --- /dev/null +++ b/tests/api/koreader.rs @@ -0,0 +1,165 @@ +#[path = "../common/mod.rs"] +mod common; + +use codex::api::routes::koreader::dto::progress::{AuthorizedDto, DocumentProgressDto}; +use codex::db::ScanningStrategy; +use codex::db::repositories::{ + BookRepository, LibraryRepository, ReadProgressRepository, SeriesRepository, UserRepository, +}; +use codex::utils::password; +use common::*; +use hyper::StatusCode; + +async fn setup_admin_and_token( + db: &sea_orm::DatabaseConnection, + state: &codex::api::extractors::AppState, +) -> (uuid::Uuid, String) { + let password_hash = password::hash_password("admin123").unwrap(); + let user = create_test_user("admin", "admin@example.com", &password_hash, true); + let created = UserRepository::create(db, &user).await.unwrap(); + let token = state + .jwt_service + .generate_token(created.id, created.username.clone(), created.get_role()) + .unwrap(); + (created.id, token) +} + +/// POST /koreader/users/create always returns 403 +#[tokio::test] +async fn test_koreader_create_user_returns_403() { + let (db, _tmp) = setup_test_db().await; + let state = create_test_app_state(db).await; + let app = create_test_router_with_koreader(state); + + let request = post_request("/koreader/users/create"); + let (status, _body) = make_request(app, request).await; + assert_eq!(status, StatusCode::FORBIDDEN); +} + +/// GET /koreader/users/auth returns 200 with valid auth +#[tokio::test] +async fn test_koreader_auth_with_bearer_token() { + let (db, _tmp) = setup_test_db().await; + let state = create_test_app_state(db.clone()).await; + let (_user_id, token) = setup_admin_and_token(&db, &state).await; + let app = create_test_router_with_koreader(state); + + let request = get_request_with_auth("/koreader/users/auth", &token); + let (status, body) = make_json_request::(app, request).await; + + assert_eq!(status, StatusCode::OK); + assert_eq!(body.unwrap().authorized, "OK"); +} + +/// GET /koreader/users/auth returns 200 with basic auth +#[tokio::test] +async fn test_koreader_auth_with_basic_auth() { + let (db, _tmp) = setup_test_db().await; + + let password_hash = password::hash_password("testpass").unwrap(); + let user = create_test_user("testuser", "test@example.com", &password_hash, true); + UserRepository::create(&db, &user).await.unwrap(); + + let state = create_test_app_state(db).await; + let app = create_test_router_with_koreader(state); + + let request = get_request_with_basic_auth("/koreader/users/auth", "testuser", "testpass"); + let (status, body) = make_json_request::(app, request).await; + + assert_eq!(status, StatusCode::OK); + assert_eq!(body.unwrap().authorized, "OK"); +} + +/// GET /koreader/users/auth returns 401 without auth +#[tokio::test] +async fn test_koreader_auth_without_credentials() { + let (db, _tmp) = setup_test_db().await; + let state = create_test_app_state(db).await; + let app = create_test_router_with_koreader(state); + + let request = get_request("/koreader/users/auth"); + let (status, _body) = make_request(app, request).await; + assert_eq!(status, StatusCode::UNAUTHORIZED); +} + +/// PUT /koreader/syncs/progress updates and GET retrieves progress +#[tokio::test] +async fn test_koreader_sync_progress_roundtrip() { + let (db, _tmp) = setup_test_db().await; + let state = create_test_app_state(db.clone()).await; + let (user_id, token) = setup_admin_and_token(&db, &state).await; + + // Create library, series, and book with a koreader_hash + let library = + LibraryRepository::create(&db, "Test Library", "/test", ScanningStrategy::Default) + .await + .unwrap(); + let series = SeriesRepository::create(&db, library.id, "Test Series", None) + .await + .unwrap(); + + let koreader_hash = "abc123def456"; + let mut book = create_test_book( + series.id, + library.id, + "/test/book1.cbz", + "book1.cbz", + "hash1", + "cbz", + 100, + ); + book.koreader_hash = Some(koreader_hash.to_string()); + let book = BookRepository::create(&db, &book, None).await.unwrap(); + + // PUT progress + let progress = serde_json::json!({ + "document": koreader_hash, + "progress": "42", + "percentage": 0.42, + "device": "test-device", + "device_id": "device-123" + }); + + let app = create_test_router_with_koreader(state.clone()); + let request = put_request_with_auth( + "/koreader/syncs/progress", + &serde_json::to_string(&progress).unwrap(), + &token, + ); + let (status, _body) = make_request(app, request).await; + assert_eq!(status, StatusCode::OK); + + // Verify progress was stored in DB + let stored = ReadProgressRepository::get_by_user_and_book(&db, user_id, book.id) + .await + .unwrap() + .expect("Progress should exist"); + assert_eq!(stored.current_page, 42); + assert_eq!(stored.progress_percentage, Some(0.42)); + + // GET progress back + let app = create_test_router_with_koreader(state); + let request = get_request_with_auth( + &format!("/koreader/syncs/progress/{}", koreader_hash), + &token, + ); + let (status, body) = make_json_request::(app, request).await; + assert_eq!(status, StatusCode::OK); + let body = body.unwrap(); + assert_eq!(body.document, koreader_hash); + assert_eq!(body.progress, "42"); + assert!((body.percentage - 0.42).abs() < 0.001); +} + +/// GET /koreader/syncs/progress/{hash} returns 404 for unknown hash +#[tokio::test] +async fn test_koreader_get_progress_unknown_hash() { + let (db, _tmp) = setup_test_db().await; + let state = create_test_app_state(db.clone()).await; + let (_user_id, token) = setup_admin_and_token(&db, &state).await; + let app = create_test_router_with_koreader(state); + + let request = get_request_with_auth("/koreader/syncs/progress/nonexistent_hash", &token); + let (status, _body) = make_request(app, request).await; + assert_eq!(status, StatusCode::NOT_FOUND); +} diff --git a/tests/api/metadata_locks.rs b/tests/api/metadata_locks.rs index 1d9dd9f0..de3d8a10 100644 --- a/tests/api/metadata_locks.rs +++ b/tests/api/metadata_locks.rs @@ -1019,6 +1019,7 @@ fn create_test_book_model( updated_at: Utc::now(), thumbnail_path: None, thumbnail_generated_at: None, + koreader_hash: None, } } diff --git a/tests/api/opds.rs b/tests/api/opds.rs index e3bba834..76864f52 100644 --- a/tests/api/opds.rs +++ b/tests/api/opds.rs @@ -534,6 +534,7 @@ fn create_test_book_model( updated_at: Utc::now(), thumbnail_path: None, thumbnail_generated_at: None, + koreader_hash: None, } } diff --git a/tests/api/opds2.rs b/tests/api/opds2.rs index 509e300f..11e91b94 100644 --- a/tests/api/opds2.rs +++ b/tests/api/opds2.rs @@ -915,6 +915,7 @@ fn create_test_book_model( updated_at: Utc::now(), thumbnail_path: None, thumbnail_generated_at: None, + koreader_hash: None, } } diff --git a/tests/api/pages.rs b/tests/api/pages.rs index a674fe41..1f9a5dff 100644 --- a/tests/api/pages.rs +++ b/tests/api/pages.rs @@ -62,6 +62,7 @@ fn create_test_book_model( updated_at: Utc::now(), thumbnail_path: None, thumbnail_generated_at: None, + koreader_hash: None, } } diff --git a/tests/api/read_progress.rs b/tests/api/read_progress.rs index d825d803..302d3cd1 100644 --- a/tests/api/read_progress.rs +++ b/tests/api/read_progress.rs @@ -57,6 +57,7 @@ fn create_test_book_model( updated_at: Utc::now(), thumbnail_path: None, thumbnail_generated_at: None, + koreader_hash: None, } } diff --git a/tests/api/series.rs b/tests/api/series.rs index 3244910d..1ee5d870 100644 --- a/tests/api/series.rs +++ b/tests/api/series.rs @@ -1325,6 +1325,7 @@ fn create_test_book( updated_at: Utc::now(), thumbnail_path: None, thumbnail_generated_at: None, + koreader_hash: None, } } diff --git a/tests/api/tags.rs b/tests/api/tags.rs index 764f422c..332e08f6 100644 --- a/tests/api/tags.rs +++ b/tests/api/tags.rs @@ -576,6 +576,7 @@ fn create_test_book_model( updated_at: Utc::now(), thumbnail_path: None, thumbnail_generated_at: None, + koreader_hash: None, } } diff --git a/tests/common/fixtures.rs b/tests/common/fixtures.rs index a618585a..3ed58284 100644 --- a/tests/common/fixtures.rs +++ b/tests/common/fixtures.rs @@ -84,6 +84,7 @@ pub fn create_test_book( updated_at: now, thumbnail_path: None, thumbnail_generated_at: None, + koreader_hash: None, } } @@ -204,6 +205,7 @@ pub async fn create_test_book_with_hash( created_at: Utc::now(), thumbnail_path: None, thumbnail_generated_at: None, + koreader_hash: None, updated_at: Utc::now(), }; diff --git a/tests/common/http.rs b/tests/common/http.rs index 90f5ae6f..6664c6d0 100644 --- a/tests/common/http.rs +++ b/tests/common/http.rs @@ -235,6 +235,26 @@ pub async fn setup_test_app_with_komga(db: DatabaseConnection) -> (Arc (state, router) } +/// Helper to create a test config with KOReader API enabled +pub fn create_test_config_with_koreader() -> Config { + let mut config = create_test_config(); + config.koreader_api.enabled = true; + config +} + +/// Helper to create the API router with KOReader API enabled +pub fn create_test_router_with_koreader(state: Arc) -> Router { + let config = create_test_config_with_koreader(); + create_router(state, &config) +} + +/// Helper to set up a test app with KOReader API enabled +pub async fn setup_test_app_with_koreader(db: DatabaseConnection) -> (Arc, Router) { + let state = create_test_app_state(db).await; + let router = create_test_router_with_koreader(state.clone()); + (state, router) +} + /// Helper to create a GET request with Basic Auth header pub fn get_request_with_basic_auth(uri: &str, username: &str, password: &str) -> Request { use base64::{Engine as _, engine::general_purpose::STANDARD}; diff --git a/tests/db/postgres.rs b/tests/db/postgres.rs index 7bbce4a5..8e9ff12d 100644 --- a/tests/db/postgres.rs +++ b/tests/db/postgres.rs @@ -145,6 +145,7 @@ async fn test_postgres_series_book_relationship() { updated_at: now, thumbnail_path: None, thumbnail_generated_at: None, + koreader_hash: None, }; let book = BookRepository::create(conn, &book_model, None) @@ -291,6 +292,7 @@ async fn test_postgres_metrics_repository() { updated_at: now, thumbnail_path: None, thumbnail_generated_at: None, + koreader_hash: None, }; BookRepository::create(conn, &book_model, None) diff --git a/tests/scanner/book_analysis_metadata.rs b/tests/scanner/book_analysis_metadata.rs index 7f66c7f9..efd258e1 100644 --- a/tests/scanner/book_analysis_metadata.rs +++ b/tests/scanner/book_analysis_metadata.rs @@ -61,6 +61,7 @@ async fn create_test_book( updated_at: Utc::now(), thumbnail_path: None, thumbnail_generated_at: None, + koreader_hash: None, }; let created_book = BookRepository::create(db.sea_orm_connection(), &book, None).await?; @@ -105,6 +106,7 @@ async fn create_test_book_with_strategy( updated_at: Utc::now(), thumbnail_path: None, thumbnail_generated_at: None, + koreader_hash: None, }; let created_book = BookRepository::create(db.sea_orm_connection(), &book, None).await?; @@ -866,6 +868,7 @@ async fn test_series_metadata_populated_from_first_book() -> Result<()> { updated_at: now, thumbnail_path: None, thumbnail_generated_at: None, + koreader_hash: None, }; BookRepository::create(db.sea_orm_connection(), &book1, None).await?; @@ -924,6 +927,7 @@ async fn test_series_metadata_populated_from_first_book() -> Result<()> { updated_at: now, thumbnail_path: None, thumbnail_generated_at: None, + koreader_hash: None, }; BookRepository::create(db.sea_orm_connection(), &book2, None).await?; @@ -1092,6 +1096,7 @@ async fn test_series_title_sort_populated_from_title() -> Result<()> { updated_at: now, thumbnail_path: None, thumbnail_generated_at: None, + koreader_hash: None, }; BookRepository::create(db.sea_orm_connection(), &book, None).await?; @@ -1188,6 +1193,7 @@ async fn test_series_title_sort_respects_lock() -> Result<()> { updated_at: now, thumbnail_path: None, thumbnail_generated_at: None, + koreader_hash: None, }; BookRepository::create(db.sea_orm_connection(), &book, None).await?; @@ -1291,6 +1297,7 @@ async fn test_series_title_sort_not_overwritten_if_already_set() -> Result<()> { updated_at: now, thumbnail_path: None, thumbnail_generated_at: None, + koreader_hash: None, }; BookRepository::create(db.sea_orm_connection(), &book, None).await?; @@ -1368,6 +1375,7 @@ async fn test_series_title_sort_populated_without_comic_info() -> Result<()> { updated_at: now, thumbnail_path: None, thumbnail_generated_at: None, + koreader_hash: None, }; BookRepository::create(db.sea_orm_connection(), &book, None).await?; diff --git a/tests/scanner/force_analysis.rs b/tests/scanner/force_analysis.rs index d854f3b9..dc89ccea 100644 --- a/tests/scanner/force_analysis.rs +++ b/tests/scanner/force_analysis.rs @@ -43,6 +43,7 @@ async fn create_analyzed_book( updated_at: Utc::now(), thumbnail_path: None, thumbnail_generated_at: None, + koreader_hash: None, }; BookRepository::create(db_conn, &book, None).await?;