From 5b16768f6de68c2a212d3182644c9e1c3a6c7db6 Mon Sep 17 00:00:00 2001 From: Sylvain Cau Date: Sat, 14 Mar 2026 16:00:09 -0700 Subject: [PATCH 1/9] feat(reader): add R2Progression support for cross-device EPUB reading sync Add R2Progression (Readium/OPDS 2.0 standard) storage and endpoints to enable EPUB reading position sync between Codex web reader and third-party apps like Komic. - Add r2_progression TEXT column to read_progress table - Add GET/PUT /books/{id}/progression endpoints to both Komga-compatible and V1 APIs, storing the full R2Progression JSON alongside legacy fields (current_page, progress_percentage, completed) for backwards compatibility - Update the EPUB reader frontend to save and restore R2Progression with CFI locations for precise cross-device position restoration - Restore priority: localStorage CFI > R2Progression CFI > R2Progression totalProgression > legacy progress_percentage Includes integration tests for both Komga and V1 progression endpoints. --- migration/src/lib.rs | 5 + .../m20260314_000061_add_r2_progression.rs | 29 ++ src/api/routes/komga/handlers/mod.rs | 7 +- .../routes/komga/handlers/read_progress.rs | 144 ++++++++++ src/api/routes/komga/routes/read_progress.rs | 8 +- src/api/routes/koreader/handlers/sync.rs | 1 + src/api/routes/v1/handlers/read_progress.rs | 116 ++++++++ src/api/routes/v1/routes/books.rs | 4 + src/db/entities/read_progress.rs | 3 + src/db/repositories/read_progress.rs | 12 +- tests/api/komga.rs | 252 ++++++++++++++++++ tests/api/read_progress.rs | 141 ++++++++++ tests/db/repositories.rs | 1 + web/src/api/readProgress.ts | 41 +++ web/src/components/reader/EpubReader.tsx | 24 +- .../reader/hooks/useEpubProgress.test.tsx | 35 ++- .../reader/hooks/useEpubProgress.ts | 146 +++++++--- 17 files changed, 901 insertions(+), 68 deletions(-) create mode 100644 migration/src/m20260314_000061_add_r2_progression.rs diff --git a/migration/src/lib.rs b/migration/src/lib.rs index 161b6820..9e8a19e5 100644 --- a/migration/src/lib.rs +++ b/migration/src/lib.rs @@ -123,6 +123,9 @@ mod m20260222_000059_add_search_title; // Add koreader_hash column for KOReader sync mod m20260309_000060_add_koreader_hash; +// Add r2_progression column for Readium/OPDS 2.0 EPUB progress sync +mod m20260314_000061_add_r2_progression; + pub struct Migrator; #[async_trait::async_trait] @@ -219,6 +222,8 @@ impl MigratorTrait for Migrator { Box::new(m20260222_000059_add_search_title::Migration), // Add koreader_hash for KOReader sync Box::new(m20260309_000060_add_koreader_hash::Migration), + // Add r2_progression for Readium EPUB progress sync + Box::new(m20260314_000061_add_r2_progression::Migration), ] } } diff --git a/migration/src/m20260314_000061_add_r2_progression.rs b/migration/src/m20260314_000061_add_r2_progression.rs new file mode 100644 index 00000000..d96b3d0a --- /dev/null +++ b/migration/src/m20260314_000061_add_r2_progression.rs @@ -0,0 +1,29 @@ +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> { + manager + .alter_table( + Table::alter() + .table(Alias::new("read_progress")) + .add_column(ColumnDef::new(Alias::new("r2_progression")).text()) + .to_owned(), + ) + .await + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .alter_table( + Table::alter() + .table(Alias::new("read_progress")) + .drop_column(Alias::new("r2_progression")) + .to_owned(), + ) + .await + } +} diff --git a/src/api/routes/komga/handlers/mod.rs b/src/api/routes/komga/handlers/mod.rs index b404f671..5bef3276 100644 --- a/src/api/routes/komga/handlers/mod.rs +++ b/src/api/routes/komga/handlers/mod.rs @@ -19,7 +19,8 @@ pub use books::{ pub use libraries::{get_library, get_library_thumbnail, list_libraries}; pub use pages::{get_page, get_page_thumbnail, list_pages}; pub use read_progress::{ - delete_progress, mark_series_as_read, mark_series_as_unread, update_progress, + delete_progress, get_progression, mark_series_as_read, mark_series_as_unread, put_progression, + update_progress, }; pub use series::{ get_series, get_series_books, get_series_new, get_series_thumbnail, get_series_updated, @@ -48,8 +49,8 @@ pub use pages::{__path_get_page, __path_get_page_thumbnail, __path_list_pages}; #[doc(hidden)] #[allow(unused_imports)] pub use read_progress::{ - __path_delete_progress, __path_mark_series_as_read, __path_mark_series_as_unread, - __path_update_progress, + __path_delete_progress, __path_get_progression, __path_mark_series_as_read, + __path_mark_series_as_unread, __path_put_progression, __path_update_progress, }; #[doc(hidden)] #[allow(unused_imports)] diff --git a/src/api/routes/komga/handlers/read_progress.rs b/src/api/routes/komga/handlers/read_progress.rs index b5c63a2d..b4deb5d4 100644 --- a/src/api/routes/komga/handlers/read_progress.rs +++ b/src/api/routes/komga/handlers/read_progress.rs @@ -15,6 +15,7 @@ use crate::require_permission; use axum::{ extract::{Path, State}, http::StatusCode, + response::{IntoResponse, Response}, }; use std::sync::Arc; use uuid::Uuid; @@ -196,6 +197,149 @@ mod tests { } } +// ============================================================================ +// R2Progression (Readium) Handlers +// ============================================================================ + +/// Get book progression (R2Progression / Readium standard) +/// +/// Returns the stored R2Progression JSON for EPUB reading position sync. +/// Used by Komic and other Readium-compatible readers. +/// +/// ## Endpoint +/// `GET /{prefix}/api/v1/books/{bookId}/progression` +/// +/// ## Response +/// - 200 with R2Progression JSON if progression exists +/// - 204 No Content if no progression exists +#[utoipa::path( + get, + path = "/{prefix}/api/v1/books/{book_id}/progression", + responses( + (status = 200, description = "Progression data", content_type = "application/json"), + (status = 204, description = "No progression exists"), + (status = 401, description = "Unauthorized"), + (status = 404, description = "Book not found"), + ), + params( + ("prefix" = String, Path, description = "Komga API prefix (default: komga)"), + ("book_id" = Uuid, Path, description = "Book ID") + ), + security( + ("jwt_bearer" = []), + ("api_key" = []) + ), + tag = "Komga" +)] +pub async fn get_progression( + State(state): State>, + FlexibleAuthContext(auth): FlexibleAuthContext, + Path(book_id): Path, +) -> Result { + require_permission!(auth, Permission::BooksRead)?; + + // Verify book exists + BookRepository::get_by_id(&state.db, book_id) + .await + .map_err(|e| ApiError::Internal(format!("Failed to fetch book: {}", e)))? + .ok_or_else(|| ApiError::NotFound("Book not found".to_string()))?; + + let progress = ReadProgressRepository::get_by_user_and_book(&state.db, auth.user_id, book_id) + .await + .map_err(|e| ApiError::Internal(format!("Failed to fetch progress: {}", e)))?; + + match progress.and_then(|p| p.r2_progression) { + Some(json_str) => { + let json_value: serde_json::Value = serde_json::from_str(&json_str) + .map_err(|e| ApiError::Internal(format!("Invalid R2Progression JSON: {}", e)))?; + Ok(axum::Json(json_value).into_response()) + } + None => Ok(StatusCode::NO_CONTENT.into_response()), + } +} + +/// Update book progression (R2Progression / Readium standard) +/// +/// Stores R2Progression JSON and also updates the underlying read progress +/// (current_page, progress_percentage, completed) for backwards compatibility. +/// +/// ## Endpoint +/// `PUT /{prefix}/api/v1/books/{bookId}/progression` +/// +/// ## Request Body +/// R2Progression JSON with `device`, `locator`, and `modified` fields +/// +/// ## Response +/// - 204 No Content on success +#[utoipa::path( + put, + path = "/{prefix}/api/v1/books/{book_id}/progression", + request_body = serde_json::Value, + responses( + (status = 204, description = "Progression updated successfully"), + (status = 401, description = "Unauthorized"), + (status = 404, description = "Book not found"), + ), + params( + ("prefix" = String, Path, description = "Komga API prefix (default: komga)"), + ("book_id" = Uuid, Path, description = "Book ID") + ), + security( + ("jwt_bearer" = []), + ("api_key" = []) + ), + tag = "Komga" +)] +pub async fn put_progression( + State(state): State>, + FlexibleAuthContext(auth): FlexibleAuthContext, + Path(book_id): Path, + axum::Json(body): axum::Json, +) -> Result { + require_permission!(auth, Permission::BooksRead)?; + + let book = BookRepository::get_by_id(&state.db, book_id) + .await + .map_err(|e| ApiError::Internal(format!("Failed to fetch book: {}", e)))? + .ok_or_else(|| ApiError::NotFound("Book not found".to_string()))?; + + // Extract totalProgression from the R2Progression locator + let total_progression = body + .get("locator") + .and_then(|l| l.get("locations")) + .and_then(|l| l.get("totalProgression")) + .and_then(|v| v.as_f64()) + .unwrap_or(0.0); + + // Derive page and completion from totalProgression + let current_page = if book.page_count > 0 { + (total_progression * book.page_count as f64) + .round() + .max(1.0) as i32 + } else { + 1 + }; + let completed = + total_progression >= 0.98 || (book.page_count > 0 && current_page >= book.page_count); + + let json_str = serde_json::to_string(&body) + .map_err(|e| ApiError::Internal(format!("Failed to serialize R2Progression: {}", e)))?; + + ReadProgressRepository::upsert_with_percentage( + &state.db, + auth.user_id, + book_id, + current_page, + Some(total_progression), + completed, + Some(json_str), + ) + .await + .map_err(|e| ApiError::Internal(format!("Failed to update progression: {}", e)))?; + + Ok(StatusCode::NO_CONTENT) +} + // ============================================================================ // Series Read Progress Handlers // ============================================================================ diff --git a/src/api/routes/komga/routes/read_progress.rs b/src/api/routes/komga/routes/read_progress.rs index aab23a80..80213797 100644 --- a/src/api/routes/komga/routes/read_progress.rs +++ b/src/api/routes/komga/routes/read_progress.rs @@ -6,7 +6,7 @@ use super::super::handlers; use crate::api::extractors::AppState; use axum::{ Router, - routing::{patch, post}, + routing::{get, patch, post}, }; use std::sync::Arc; @@ -15,6 +15,8 @@ use std::sync::Arc; /// Routes: /// - `PATCH /books/{book_id}/read-progress` - Update reading progress /// - `DELETE /books/{book_id}/read-progress` - Delete reading progress (mark as unread) +/// - `GET /books/{book_id}/progression` - Get R2Progression (Readium) +/// - `PUT /books/{book_id}/progression` - Update R2Progression (Readium) /// - `POST /series/{series_id}/read-progress` - Mark all books in series as read /// - `DELETE /series/{series_id}/read-progress` - Mark all books in series as unread pub fn routes(_state: Arc) -> Router> { @@ -23,6 +25,10 @@ pub fn routes(_state: Arc) -> Router> { "/books/{book_id}/read-progress", patch(handlers::update_progress).delete(handlers::delete_progress), ) + .route( + "/books/{book_id}/progression", + get(handlers::get_progression).put(handlers::put_progression), + ) .route( "/series/{series_id}/read-progress", post(handlers::mark_series_as_read).delete(handlers::mark_series_as_unread), diff --git a/src/api/routes/koreader/handlers/sync.rs b/src/api/routes/koreader/handlers/sync.rs index 422f9e97..c7584bec 100644 --- a/src/api/routes/koreader/handlers/sync.rs +++ b/src/api/routes/koreader/handlers/sync.rs @@ -112,6 +112,7 @@ pub async fn update_progress( current_page, Some(request.percentage), completed, + None, ) .await .map_err(|e| ApiError::Internal(format!("Failed to update progress: {}", e)))?; diff --git a/src/api/routes/v1/handlers/read_progress.rs b/src/api/routes/v1/handlers/read_progress.rs index 92f38c99..a1dc090e 100644 --- a/src/api/routes/v1/handlers/read_progress.rs +++ b/src/api/routes/v1/handlers/read_progress.rs @@ -7,6 +7,7 @@ use axum::{ Json, extract::{Path, State}, http::StatusCode, + response::{IntoResponse, Response}, }; use std::sync::Arc; use utoipa::OpenApi; @@ -21,6 +22,8 @@ use uuid::Uuid; get_user_progress, mark_book_as_read, mark_book_as_unread, + get_progression, + put_progression, ), components(schemas( UpdateProgressRequest, @@ -88,6 +91,7 @@ pub async fn update_reading_progress( request.current_page, request.progress_percentage, completed, + None, ) .await .map_err(|e| ApiError::Internal(format!("Failed to update reading progress: {}", e)))?; @@ -270,3 +274,115 @@ pub async fn mark_book_as_unread( Ok(StatusCode::NO_CONTENT) } + +/// Get book progression (R2Progression / Readium standard) +/// +/// Returns the stored R2Progression JSON for EPUB reading position sync. +/// Returns 200 with the progression data, or 204 if no progression exists. +#[utoipa::path( + get, + path = "/api/v1/books/{book_id}/progression", + responses( + (status = 200, description = "Progression data", content_type = "application/json"), + (status = 204, description = "No progression exists"), + (status = 401, description = "Unauthorized"), + (status = 404, description = "Book not found"), + ), + security( + ("bearer_auth" = []), + ("api_key" = []) + ), + tag = "Reading Progress" +)] +pub async fn get_progression( + State(state): State>, + auth: AuthContext, + Path(book_id): Path, +) -> Result { + auth.require_permission(&Permission::BooksRead)?; + + BookRepository::get_by_id(&state.db, book_id) + .await + .map_err(|e| ApiError::Internal(format!("Failed to fetch book: {}", e)))? + .ok_or_else(|| ApiError::NotFound("Book not found".to_string()))?; + + let progress = ReadProgressRepository::get_by_user_and_book(&state.db, auth.user_id, book_id) + .await + .map_err(|e| ApiError::Internal(format!("Failed to fetch progress: {}", e)))?; + + match progress.and_then(|p| p.r2_progression) { + Some(json_str) => { + let json_value: serde_json::Value = serde_json::from_str(&json_str) + .map_err(|e| ApiError::Internal(format!("Invalid R2Progression JSON: {}", e)))?; + Ok(Json(json_value).into_response()) + } + None => Ok(StatusCode::NO_CONTENT.into_response()), + } +} + +/// Update book progression (R2Progression / Readium standard) +/// +/// Stores R2Progression JSON and also updates the underlying read progress +/// (current_page, progress_percentage, completed) for backwards compatibility. +#[utoipa::path( + put, + path = "/api/v1/books/{book_id}/progression", + request_body = serde_json::Value, + responses( + (status = 204, description = "Progression updated successfully"), + (status = 401, description = "Unauthorized"), + (status = 404, description = "Book not found"), + ), + security( + ("bearer_auth" = []), + ("api_key" = []) + ), + tag = "Reading Progress" +)] +pub async fn put_progression( + State(state): State>, + auth: AuthContext, + Path(book_id): Path, + Json(body): Json, +) -> Result { + auth.require_permission(&Permission::BooksRead)?; + + let book = BookRepository::get_by_id(&state.db, book_id) + .await + .map_err(|e| ApiError::Internal(format!("Failed to fetch book: {}", e)))? + .ok_or_else(|| ApiError::NotFound("Book not found".to_string()))?; + + let total_progression = body + .get("locator") + .and_then(|l| l.get("locations")) + .and_then(|l| l.get("totalProgression")) + .and_then(|v| v.as_f64()) + .unwrap_or(0.0); + + let current_page = if book.page_count > 0 { + (total_progression * book.page_count as f64) + .round() + .max(1.0) as i32 + } else { + 1 + }; + let completed = + total_progression >= 0.98 || (book.page_count > 0 && current_page >= book.page_count); + + let json_str = serde_json::to_string(&body) + .map_err(|e| ApiError::Internal(format!("Failed to serialize R2Progression: {}", e)))?; + + ReadProgressRepository::upsert_with_percentage( + &state.db, + auth.user_id, + book_id, + current_page, + Some(total_progression), + completed, + Some(json_str), + ) + .await + .map_err(|e| ApiError::Internal(format!("Failed to update progression: {}", e)))?; + + Ok(StatusCode::NO_CONTENT) +} diff --git a/src/api/routes/v1/routes/books.rs b/src/api/routes/v1/routes/books.rs index a572a445..148cbc61 100644 --- a/src/api/routes/v1/routes/books.rs +++ b/src/api/routes/v1/routes/books.rs @@ -159,6 +159,10 @@ pub fn routes(_state: Arc) -> Router> { "/books/{book_id}/progress", delete(handlers::delete_reading_progress), ) + .route( + "/books/{book_id}/progression", + get(handlers::get_progression).put(handlers::put_progression), + ) .route("/progress", get(handlers::get_user_progress)) // Mark as read/unread routes .route("/books/{book_id}/read", post(handlers::mark_book_as_read)) diff --git a/src/db/entities/read_progress.rs b/src/db/entities/read_progress.rs index 191842bc..bad0d812 100644 --- a/src/db/entities/read_progress.rs +++ b/src/db/entities/read_progress.rs @@ -17,6 +17,9 @@ pub struct Model { pub started_at: DateTime, pub updated_at: DateTime, pub completed_at: Option>, + /// R2Progression JSON (Readium standard) for EPUB position sync + #[sea_orm(column_type = "Text", nullable)] + pub r2_progression: Option, } #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] diff --git a/src/db/repositories/read_progress.rs b/src/db/repositories/read_progress.rs index 8f3e5f9a..9deec32d 100644 --- a/src/db/repositories/read_progress.rs +++ b/src/db/repositories/read_progress.rs @@ -48,11 +48,13 @@ impl ReadProgressRepository { current_page: i32, completed: bool, ) -> Result { - Self::upsert_with_percentage(db, user_id, book_id, current_page, None, completed).await + Self::upsert_with_percentage(db, user_id, book_id, current_page, None, completed, None) + .await } /// Create or update reading progress for a user and book with optional percentage - /// The percentage field is primarily used for EPUB books with reflowable content + /// The percentage field is primarily used for EPUB books with reflowable content. + /// The r2_progression field stores the full R2Progression JSON for Readium/OPDS 2.0 sync. pub async fn upsert_with_percentage( db: &DatabaseConnection, user_id: Uuid, @@ -60,6 +62,7 @@ impl ReadProgressRepository { current_page: i32, progress_percentage: Option, completed: bool, + r2_progression: Option, ) -> Result { // Check if progress already exists let existing = Self::get_by_user_and_book(db, user_id, book_id).await?; @@ -75,6 +78,7 @@ impl ReadProgressRepository { progress_percentage, completed, now, + r2_progression, ) .await } else { @@ -89,6 +93,7 @@ impl ReadProgressRepository { started_at: Set(now), updated_at: Set(now), completed_at: Set(if completed { Some(now) } else { None }), + r2_progression: Set(r2_progression.clone()), }; match new_progress.insert(db).await { @@ -107,6 +112,7 @@ impl ReadProgressRepository { progress_percentage, completed, now, + r2_progression, ) .await } @@ -123,12 +129,14 @@ impl ReadProgressRepository { progress_percentage: Option, completed: bool, now: chrono::DateTime, + r2_progression: Option, ) -> Result { let mut active_model: read_progress::ActiveModel = existing_model.clone().into(); active_model.current_page = Set(current_page); active_model.progress_percentage = Set(progress_percentage); active_model.completed = Set(completed); active_model.updated_at = Set(now); + active_model.r2_progression = Set(r2_progression); // Set completed_at if just marked as completed if completed && existing_model.completed_at.is_none() { diff --git a/tests/api/komga.rs b/tests/api/komga.rs index 1a5cfde1..4d464805 100644 --- a/tests/api/komga.rs +++ b/tests/api/komga.rs @@ -4648,3 +4648,255 @@ async fn test_komga_search_series_sort_by_title_mixed_null_and_set() { titles ); } + +// ============================================================================ +// R2Progression (Readium) Tests +// ============================================================================ + +#[tokio::test] +async fn test_komga_get_progression_returns_204_when_no_progression() { + let (db, temp_dir) = setup_test_db().await; + + let library = LibraryRepository::create(&db, "Comics", "/comics", ScanningStrategy::Default) + .await + .unwrap(); + let series = SeriesRepository::create(&db, library.id, "Batman", None) + .await + .unwrap(); + let book = create_test_book( + series.id, + library.id, + "/comics/Batman/issue1.epub", + "issue1.epub", + "hash1", + "epub", + 50, + ); + let created_book = BookRepository::create(&db, &book, None).await.unwrap(); + + let state = create_test_auth_state(db.clone()).await; + let token = create_admin_and_token(&db, &state).await; + let app = create_test_router_with_komga(state); + + let uri = format!("/komga/api/v1/books/{}/progression", created_book.id); + let request = get_request_with_auth(&uri, &token); + let (status, _) = make_raw_request(app, request).await; + + assert_eq!(status, StatusCode::NO_CONTENT); +} + +#[tokio::test] +async fn test_komga_put_and_get_progression_round_trip() { + let (db, temp_dir) = setup_test_db().await; + + let library = LibraryRepository::create(&db, "Comics", "/comics", ScanningStrategy::Default) + .await + .unwrap(); + let series = SeriesRepository::create(&db, library.id, "Batman", None) + .await + .unwrap(); + let book = create_test_book( + series.id, + library.id, + "/comics/Batman/issue1.epub", + "issue1.epub", + "hash1", + "epub", + 50, + ); + let created_book = BookRepository::create(&db, &book, None).await.unwrap(); + + let state = create_test_auth_state(db.clone()).await; + let token = create_admin_and_token(&db, &state).await; + + // PUT progression + let progression_json = serde_json::json!({ + "device": { "id": "komic", "name": "Komic" }, + "locator": { + "href": "OEBPS/chapter1.xhtml", + "locations": { + "position": 10, + "progression": 0.3, + "totalProgression": 0.5 + }, + "type": "application/xhtml+xml" + }, + "modified": "2026-03-14T21:44:34.922Z" + }); + + let app = create_test_router_with_komga(state.clone()); + let uri = format!("/komga/api/v1/books/{}/progression", created_book.id); + let request = put_request_with_auth(&uri, &progression_json.to_string(), &token); + let (status, _) = make_raw_request(app, request).await; + assert_eq!(status, StatusCode::NO_CONTENT); + + // GET progression - should return what we stored + let app = create_test_router_with_komga(state.clone()); + let request = get_request_with_auth(&uri, &token); + let (status, response): (StatusCode, Option) = + make_json_request(app, request).await; + + assert_eq!(status, StatusCode::OK); + let response = response.unwrap(); + assert_eq!(response["device"]["id"], "komic"); + assert_eq!(response["locator"]["href"], "OEBPS/chapter1.xhtml"); + assert_eq!(response["locator"]["locations"]["totalProgression"], 0.5); +} + +#[tokio::test] +async fn test_komga_put_progression_updates_read_progress() { + let (db, temp_dir) = setup_test_db().await; + + let library = LibraryRepository::create(&db, "Comics", "/comics", ScanningStrategy::Default) + .await + .unwrap(); + let series = SeriesRepository::create(&db, library.id, "Batman", None) + .await + .unwrap(); + let book = create_test_book( + series.id, + library.id, + "/comics/Batman/issue1.epub", + "issue1.epub", + "hash1", + "epub", + 100, + ); + let created_book = BookRepository::create(&db, &book, None).await.unwrap(); + + let state = create_test_auth_state(db.clone()).await; + let token = create_admin_and_token(&db, &state).await; + let admin = UserRepository::get_by_username(&db, "admin") + .await + .unwrap() + .unwrap(); + + // PUT progression with 50% totalProgression + let progression_json = serde_json::json!({ + "device": { "id": "komic", "name": "Komic" }, + "locator": { + "href": "OEBPS/chapter5.xhtml", + "locations": { "totalProgression": 0.5 }, + "type": "application/xhtml+xml" + }, + "modified": "2026-03-14T21:44:34.922Z" + }); + + let app = create_test_router_with_komga(state.clone()); + let uri = format!("/komga/api/v1/books/{}/progression", created_book.id); + let request = put_request_with_auth(&uri, &progression_json.to_string(), &token); + let (status, _) = make_raw_request(app, request).await; + assert_eq!(status, StatusCode::NO_CONTENT); + + // Verify read_progress was also updated + let progress = ReadProgressRepository::get_by_user_and_book(&db, admin.id, created_book.id) + .await + .unwrap() + .unwrap(); + assert_eq!(progress.current_page, 50); // 0.5 * 100 pages + assert_eq!(progress.progress_percentage, Some(0.5)); + assert!(!progress.completed); +} + +#[tokio::test] +async fn test_komga_put_progression_auto_completes_at_98_percent() { + let (db, temp_dir) = setup_test_db().await; + + let library = LibraryRepository::create(&db, "Comics", "/comics", ScanningStrategy::Default) + .await + .unwrap(); + let series = SeriesRepository::create(&db, library.id, "Batman", None) + .await + .unwrap(); + let book = create_test_book( + series.id, + library.id, + "/comics/Batman/issue1.epub", + "issue1.epub", + "hash1", + "epub", + 100, + ); + let created_book = BookRepository::create(&db, &book, None).await.unwrap(); + + let state = create_test_auth_state(db.clone()).await; + let token = create_admin_and_token(&db, &state).await; + let admin = UserRepository::get_by_username(&db, "admin") + .await + .unwrap() + .unwrap(); + + // PUT progression with 99% totalProgression + let progression_json = serde_json::json!({ + "device": { "id": "komic", "name": "Komic" }, + "locator": { + "href": "OEBPS/last_chapter.xhtml", + "locations": { "totalProgression": 0.99 }, + "type": "application/xhtml+xml" + }, + "modified": "2026-03-14T22:00:00.000Z" + }); + + let app = create_test_router_with_komga(state.clone()); + let uri = format!("/komga/api/v1/books/{}/progression", created_book.id); + let request = put_request_with_auth(&uri, &progression_json.to_string(), &token); + let (status, _) = make_raw_request(app, request).await; + assert_eq!(status, StatusCode::NO_CONTENT); + + // Should be marked as completed + let progress = ReadProgressRepository::get_by_user_and_book(&db, admin.id, created_book.id) + .await + .unwrap() + .unwrap(); + assert!(progress.completed); + assert!(progress.completed_at.is_some()); +} + +#[tokio::test] +async fn test_komga_put_progression_without_auth() { + let (db, temp_dir) = setup_test_db().await; + + let library = LibraryRepository::create(&db, "Comics", "/comics", ScanningStrategy::Default) + .await + .unwrap(); + let series = SeriesRepository::create(&db, library.id, "Batman", None) + .await + .unwrap(); + let book = create_test_book( + series.id, + library.id, + "/comics/Batman/issue1.epub", + "issue1.epub", + "hash1", + "epub", + 50, + ); + let created_book = BookRepository::create(&db, &book, None).await.unwrap(); + + let state = create_test_auth_state(db.clone()).await; + let app = create_test_router_with_komga(state); + + let uri = format!("/komga/api/v1/books/{}/progression", created_book.id); + let body = r#"{"device":{"id":"test","name":"Test"},"locator":{"href":"x","locations":{"totalProgression":0.1},"type":"text/html"},"modified":"2026-01-01T00:00:00Z"}"#; + let request = put_request_with_auth(&uri, body, "invalid-token"); + let (status, _) = make_raw_request(app, request).await; + + assert_eq!(status, StatusCode::UNAUTHORIZED); +} + +#[tokio::test] +async fn test_komga_put_progression_book_not_found() { + let (db, temp_dir) = setup_test_db().await; + + let state = create_test_auth_state(db.clone()).await; + let token = create_admin_and_token(&db, &state).await; + let app = create_test_router_with_komga(state); + + let fake_id = uuid::Uuid::new_v4(); + let uri = format!("/komga/api/v1/books/{}/progression", fake_id); + let body = r#"{"device":{"id":"test","name":"Test"},"locator":{"href":"x","locations":{"totalProgression":0.1},"type":"text/html"},"modified":"2026-01-01T00:00:00Z"}"#; + let request = put_request_with_auth(&uri, body, &token); + let (status, _) = make_raw_request(app, request).await; + + assert_eq!(status, StatusCode::NOT_FOUND); +} diff --git a/tests/api/read_progress.rs b/tests/api/read_progress.rs index 302d3cd1..9da61721 100644 --- a/tests/api/read_progress.rs +++ b/tests/api/read_progress.rs @@ -694,3 +694,144 @@ async fn test_update_progress_returns_not_found_for_missing_book() { error.message ); } + +// ============================================================================ +// R2Progression (Readium) Tests - V1 API +// ============================================================================ + +#[tokio::test] +async fn test_v1_get_progression_returns_204_when_no_progression() { + let (db, _temp_dir) = setup_test_db().await; + + let library = LibraryRepository::create(&db, "Comics", "/comics", ScanningStrategy::Default) + .await + .unwrap(); + let series = SeriesRepository::create(&db, library.id, "Test Series", None) + .await + .unwrap(); + let book = create_test_book_model( + series.id, + library.id, + "/comics/test.epub", + "test.epub", + None, + 50, + ); + let created_book = BookRepository::create(&db, &book, None).await.unwrap(); + + let (state, app) = setup_test_app(db.clone()).await; + let (_user_id, token) = create_admin_and_token(&db, &state).await; + + let uri = format!("/api/v1/books/{}/progression", created_book.id); + let request = get_request_with_auth(&uri, &token); + let (status, _) = make_raw_request(app, request).await; + + assert_eq!(status, StatusCode::NO_CONTENT); +} + +#[tokio::test] +async fn test_v1_put_and_get_progression_round_trip() { + let (db, _temp_dir) = setup_test_db().await; + + let library = LibraryRepository::create(&db, "Comics", "/comics", ScanningStrategy::Default) + .await + .unwrap(); + let series = SeriesRepository::create(&db, library.id, "Test Series", None) + .await + .unwrap(); + let book = create_test_book_model( + series.id, + library.id, + "/comics/test.epub", + "test.epub", + None, + 50, + ); + let created_book = BookRepository::create(&db, &book, None).await.unwrap(); + + let (state, _app) = setup_test_app(db.clone()).await; + let (_user_id, token) = create_admin_and_token(&db, &state).await; + + let progression_json = serde_json::json!({ + "device": { "id": "codex-web", "name": "Codex Web Reader" }, + "locator": { + "href": "OEBPS/chapter3.xhtml", + "locations": { + "position": 15, + "totalProgression": 0.6, + "cfi": "/6/14!/4/2/1:0" + }, + "type": "application/xhtml+xml" + }, + "modified": "2026-03-14T21:44:34.922Z" + }); + + // PUT progression + let (_, app) = setup_test_app(db.clone()).await; + let uri = format!("/api/v1/books/{}/progression", created_book.id); + let request = put_request_with_auth(&uri, &progression_json.to_string(), &token); + let (status, _) = make_raw_request(app, request).await; + assert_eq!(status, StatusCode::NO_CONTENT); + + // GET progression + let (_, app) = setup_test_app(db.clone()).await; + let request = get_request_with_auth(&uri, &token); + let (status, response): (StatusCode, Option) = + make_json_request(app, request).await; + + assert_eq!(status, StatusCode::OK); + let response = response.unwrap(); + assert_eq!(response["device"]["id"], "codex-web"); + assert_eq!(response["locator"]["href"], "OEBPS/chapter3.xhtml"); + assert_eq!(response["locator"]["locations"]["totalProgression"], 0.6); + assert_eq!(response["locator"]["locations"]["cfi"], "/6/14!/4/2/1:0"); +} + +#[tokio::test] +async fn test_v1_put_progression_updates_legacy_progress() { + let (db, _temp_dir) = setup_test_db().await; + + let library = LibraryRepository::create(&db, "Comics", "/comics", ScanningStrategy::Default) + .await + .unwrap(); + let series = SeriesRepository::create(&db, library.id, "Test Series", None) + .await + .unwrap(); + let book = create_test_book_model( + series.id, + library.id, + "/comics/test.epub", + "test.epub", + None, + 200, + ); + let created_book = BookRepository::create(&db, &book, None).await.unwrap(); + + let (state, app) = setup_test_app(db.clone()).await; + let (user_id, token) = create_admin_and_token(&db, &state).await; + + let progression_json = serde_json::json!({ + "device": { "id": "codex-web", "name": "Codex Web Reader" }, + "locator": { + "href": "OEBPS/chapter5.xhtml", + "locations": { "totalProgression": 0.75 }, + "type": "application/xhtml+xml" + }, + "modified": "2026-03-14T22:00:00.000Z" + }); + + let uri = format!("/api/v1/books/{}/progression", created_book.id); + let request = put_request_with_auth(&uri, &progression_json.to_string(), &token); + let (status, _) = make_raw_request(app, request).await; + assert_eq!(status, StatusCode::NO_CONTENT); + + // Verify legacy read_progress fields + let progress = ReadProgressRepository::get_by_user_and_book(&db, user_id, created_book.id) + .await + .unwrap() + .unwrap(); + assert_eq!(progress.current_page, 150); // 0.75 * 200 + assert_eq!(progress.progress_percentage, Some(0.75)); + assert!(!progress.completed); + assert!(progress.r2_progression.is_some()); +} diff --git a/tests/db/repositories.rs b/tests/db/repositories.rs index 0f47fc61..90823715 100644 --- a/tests/db/repositories.rs +++ b/tests/db/repositories.rs @@ -234,6 +234,7 @@ async fn test_user_read_progress() { started_at: Set(Utc::now()), updated_at: Set(Utc::now()), completed_at: Set(None), + r2_progression: Set(None), }; let progress = progress.insert(conn).await.unwrap(); diff --git a/web/src/api/readProgress.ts b/web/src/api/readProgress.ts index 8e296ea0..38631a6f 100644 --- a/web/src/api/readProgress.ts +++ b/web/src/api/readProgress.ts @@ -6,6 +6,23 @@ export type ReadProgressResponse = export type UpdateProgressRequest = components["schemas"]["UpdateProgressRequest"]; +/** Readium R2Progression format for EPUB position sync */ +export interface R2Progression { + device: { id: string; name: string }; + locator: { + href: string; + locations: { + position?: number; + progression?: number; + totalProgression: number; + /** Codex extension: epub.js CFI for precise position restoration */ + cfi?: string; + }; + type: string; + }; + modified: string; +} + export const readProgressApi = { /** * Get reading progress for a book @@ -38,4 +55,28 @@ export const readProgressApi = { delete: async (bookId: string): Promise => { await api.delete(`/books/${bookId}/progress`); }, + + /** + * Get R2Progression for a book (Readium standard) + * Returns null if no progression exists (204 response) + */ + getProgression: async (bookId: string): Promise => { + const response = await api.get( + `/books/${bookId}/progression`, + { + validateStatus: (status) => status === 200 || status === 204, + }, + ); + return response.status === 204 ? null : response.data; + }, + + /** + * Update R2Progression for a book (Readium standard) + */ + updateProgression: async ( + bookId: string, + progression: R2Progression, + ): Promise => { + await api.put(`/books/${bookId}/progression`, progression); + }, }; diff --git a/web/src/components/reader/EpubReader.tsx b/web/src/components/reader/EpubReader.tsx index 080b130d..ae049ac8 100644 --- a/web/src/components/reader/EpubReader.tsx +++ b/web/src/components/reader/EpubReader.tsx @@ -172,6 +172,7 @@ export function EpubReader({ const { getSavedLocation, initialPercentage, + initialCfi, isLoadingProgress, saveLocation, } = useEpubProgress({ @@ -379,23 +380,29 @@ export function EpubReader({ }, [locationsReady, hasAppliedStartPercent, startPercent]); // Apply API progress for cross-device sync (only if no localStorage CFI and no startPercent) + // Priority: initialCfi (from R2Progression, precise) > initialPercentage (approximate) useEffect(() => { if ( locationsReady && !isLoadingProgress && - initialPercentage !== null && + (initialCfi !== null || initialPercentage !== null) && !hasAppliedApiProgress && !hasAppliedStartPercent && !initialLocationLoadedRef.current && renditionRef.current && startPercent == null // Don't apply API progress if startPercent is provided ) { - // Navigate to percentage-based location from API - const book = renditionRef.current.book; - if (book?.locations?.length()) { - const cfi = book.locations.cfiFromPercentage(initialPercentage); - if (cfi) { - setLocation(cfi); + if (initialCfi) { + // Use precise CFI from R2Progression (saved by Codex web on another device) + setLocation(initialCfi); + } else if (initialPercentage !== null) { + // Fall back to percentage-based location (from Komic or legacy progress) + const book = renditionRef.current.book; + if (book?.locations?.length()) { + const cfi = book.locations.cfiFromPercentage(initialPercentage); + if (cfi) { + setLocation(cfi); + } } } setHasAppliedApiProgress(true); @@ -403,6 +410,7 @@ export function EpubReader({ }, [ locationsReady, isLoadingProgress, + initialCfi, initialPercentage, hasAppliedApiProgress, hasAppliedStartPercent, @@ -511,7 +519,7 @@ export function EpubReader({ // Save progress - the hook handles debouncing and duplicate detection // Note: percentage can be 0 at the start of the book, which is valid - saveLocationRef.current(cfi, percentage); + saveLocationRef.current(cfi, percentage, location.start.href); }); }, []); diff --git a/web/src/components/reader/hooks/useEpubProgress.test.tsx b/web/src/components/reader/hooks/useEpubProgress.test.tsx index 4a2f7b47..236d260d 100644 --- a/web/src/components/reader/hooks/useEpubProgress.test.tsx +++ b/web/src/components/reader/hooks/useEpubProgress.test.tsx @@ -9,6 +9,9 @@ import { useEpubProgress } from "./useEpubProgress"; vi.mock("@/api/readProgress", () => ({ readProgressApi: { update: vi.fn().mockResolvedValue({}), + updateProgression: vi.fn().mockResolvedValue(undefined), + get: vi.fn().mockResolvedValue(null), + getProgression: vi.fn().mockResolvedValue(null), }, })); @@ -95,7 +98,7 @@ describe("useEpubProgress", () => { ); act(() => { - result.current.saveLocation(mockCfi, mockPercentage); + result.current.saveLocation(mockCfi, mockPercentage, "chapter1.xhtml"); }); // Should not be saved yet (debounce) @@ -122,7 +125,7 @@ describe("useEpubProgress", () => { ); act(() => { - result.current.saveLocation(mockCfi, mockPercentage); + result.current.saveLocation(mockCfi, mockPercentage, "chapter1.xhtml"); vi.advanceTimersByTime(2000); }); @@ -141,7 +144,7 @@ describe("useEpubProgress", () => { ); act(() => { - result.current.saveLocation(mockCfi, mockPercentage); + result.current.saveLocation(mockCfi, mockPercentage, "chapter1.xhtml"); }); act(() => { @@ -149,7 +152,11 @@ describe("useEpubProgress", () => { }); act(() => { - result.current.saveLocation(mockCfi2, mockPercentage2); + result.current.saveLocation( + mockCfi2, + mockPercentage2, + "chapter2.xhtml", + ); }); act(() => { @@ -180,7 +187,7 @@ describe("useEpubProgress", () => { // Save first CFI act(() => { - result.current.saveLocation(mockCfi, mockPercentage); + result.current.saveLocation(mockCfi, mockPercentage, "chapter1.xhtml"); vi.advanceTimersByTime(1000); }); @@ -191,7 +198,7 @@ describe("useEpubProgress", () => { setItemSpy.mockClear(); act(() => { - result.current.saveLocation(mockCfi, mockPercentage); + result.current.saveLocation(mockCfi, mockPercentage, "chapter1.xhtml"); vi.advanceTimersByTime(1000); }); @@ -214,7 +221,7 @@ describe("useEpubProgress", () => { // Save first location act(() => { - result.current.saveLocation(mockCfi, 0.25); + result.current.saveLocation(mockCfi, 0.25, "chapter1.xhtml"); vi.advanceTimersByTime(1000); }); @@ -226,7 +233,7 @@ describe("useEpubProgress", () => { act(() => { // Same CFI, tiny percentage change (0.001 = 0.1%, below 0.5% threshold) - result.current.saveLocation(mockCfi, 0.251); + result.current.saveLocation(mockCfi, 0.251, "chapter1.xhtml"); vi.advanceTimersByTime(1000); }); @@ -267,7 +274,7 @@ describe("useEpubProgress", () => { ); act(() => { - result.current.saveLocation(mockCfi, mockPercentage); + result.current.saveLocation(mockCfi, mockPercentage, "chapter1.xhtml"); }); // Progress should not be saved yet (debounce is 5s) @@ -313,7 +320,7 @@ describe("useEpubProgress", () => { // Try to save a location (will be ignored due to enabled=false) act(() => { - result.current.saveLocation(mockCfi, mockPercentage); + result.current.saveLocation(mockCfi, mockPercentage, "chapter1.xhtml"); }); const setItemSpy = vi.spyOn(Storage.prototype, "setItem"); @@ -343,8 +350,12 @@ describe("useEpubProgress", () => { ); act(() => { - result1.current.saveLocation(mockCfi, mockPercentage); - result2.current.saveLocation(mockCfi2, mockPercentage2); + result1.current.saveLocation(mockCfi, mockPercentage, "chapter1.xhtml"); + result2.current.saveLocation( + mockCfi2, + mockPercentage2, + "chapter2.xhtml", + ); vi.advanceTimersByTime(2000); }); diff --git a/web/src/components/reader/hooks/useEpubProgress.ts b/web/src/components/reader/hooks/useEpubProgress.ts index e4fdac54..f15fd5bd 100644 --- a/web/src/components/reader/hooks/useEpubProgress.ts +++ b/web/src/components/reader/hooks/useEpubProgress.ts @@ -1,12 +1,15 @@ import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useCallback, useEffect, useRef } from "react"; -import { readProgressApi } from "@/api/readProgress"; +import { type R2Progression, readProgressApi } from "@/api/readProgress"; const STORAGE_KEY_PREFIX = "epub-cfi-"; // Threshold for considering percentage as "changed" (avoids saving tiny changes) const PERCENTAGE_CHANGE_THRESHOLD = 0.005; // 0.5% +const CODEX_DEVICE_ID = "codex-web"; +const CODEX_DEVICE_NAME = "Codex Web Reader"; + interface UseEpubProgressOptions { /** Book ID for storing progress */ bookId: string; @@ -23,10 +26,12 @@ interface UseEpubProgressReturn { getSavedLocation: () => string | null; /** Get the initial percentage from API (for cross-device sync) */ initialPercentage: number | null; + /** Get the initial CFI from R2Progression (for cross-device sync with Codex web) */ + initialCfi: string | null; /** Whether API progress is still loading */ isLoadingProgress: boolean; - /** Save the current CFI location and percentage */ - saveLocation: (cfi: string, percentage: number) => void; + /** Save the current CFI location, percentage, and chapter href */ + saveLocation: (cfi: string, percentage: number, href: string) => void; /** Clear saved progress for this book */ clearProgress: () => void; } @@ -35,11 +40,10 @@ interface UseEpubProgressReturn { * Hook for managing EPUB reading progress via CFI (Canonical Fragment Identifier). * * Stores CFI locations in localStorage for precise position restoration. - * Also syncs percentage-based progress to the backend API. + * Syncs R2Progression (Readium standard) to the backend API for cross-device + * and cross-app sync (e.g., between Codex web reader and Komic). + * Also syncs percentage-based progress to the backend API for backwards compat. * Uses debouncing to avoid excessive writes during rapid navigation. - * - * CFI (Canonical Fragment Identifier) is an EPUB standard for identifying - * locations within an EPUB document, allowing precise position restoration. */ export function useEpubProgress({ bookId, @@ -53,28 +57,43 @@ export function useEpubProgress({ const lastSavedPercentageRef = useRef(0); const pendingCfiRef = useRef(null); const pendingPercentageRef = useRef(0); + const pendingHrefRef = useRef(""); // Fetch initial progress from API for cross-device sync const { data: apiProgress, isLoading: isLoadingProgress } = useQuery({ queryKey: ["readProgress", bookId], queryFn: () => readProgressApi.get(bookId), enabled: enabled && !!bookId, - staleTime: 30000, // Cache for 30 seconds + staleTime: 30000, + }); + + // Fetch R2Progression for cross-device/cross-app sync + const { data: r2Progression, isLoading: isLoadingProgression } = useQuery({ + queryKey: ["progression", bookId], + queryFn: () => readProgressApi.getProgression(bookId), + enabled: enabled && !!bookId, + staleTime: 30000, }); - // Get initial percentage directly from API (stored percentage for EPUBs) - // Cast to include progress_percentage until types are regenerated + // Get initial percentage from API or R2Progression const progressWithPercentage = apiProgress as | (typeof apiProgress & { progress_percentage?: number | null }) | null | undefined; - const initialPercentage = progressWithPercentage?.progress_percentage ?? null; + + // Prefer R2Progression totalProgression, fall back to legacy progress_percentage + const initialPercentage = + r2Progression?.locator?.locations?.totalProgression ?? + progressWithPercentage?.progress_percentage ?? + null; + + // Get CFI from R2Progression if it was saved by Codex web (has cfi extension) + const initialCfi = r2Progression?.locator?.locations?.cfi ?? null; // Store refs to avoid dependency issues const bookIdRef = useRef(bookId); const totalPagesRef = useRef(totalPages); - // Keep refs up to date useEffect(() => { bookIdRef.current = bookId; totalPagesRef.current = totalPages; @@ -82,11 +101,14 @@ export function useEpubProgress({ // Initialize lastSavedPercentageRef from API progress to avoid duplicate saves useEffect(() => { - if (progressWithPercentage?.progress_percentage != null) { + if (r2Progression?.locator?.locations?.totalProgression != null) { + lastSavedPercentageRef.current = + r2Progression.locator.locations.totalProgression; + } else if (progressWithPercentage?.progress_percentage != null) { lastSavedPercentageRef.current = progressWithPercentage.progress_percentage; } - }, [progressWithPercentage]); + }, [r2Progression, progressWithPercentage]); const storageKey = `${STORAGE_KEY_PREFIX}${bookId}`; @@ -115,35 +137,57 @@ export function useEpubProgress({ [storageKey, enabled], ); - // Save progress to backend API + // Save progress to backend API (both legacy progress and R2Progression) const saveToBackend = useCallback( - (percentage: number) => { + (percentage: number, cfi: string, href: string) => { const currentBookId = bookIdRef.current; const currentTotalPages = totalPagesRef.current; - // Convert percentage to page number for backwards compatibility - // Use totalPages if available, otherwise use percentage as 0-100 scale const currentPage = currentTotalPages > 0 ? Math.max(1, Math.round(percentage * currentTotalPages)) : Math.max(1, Math.round(percentage * 100)); - const isCompleted = percentage >= 0.98; // Consider 98%+ as completed - - readProgressApi - .update(currentBookId, { - currentPage: currentPage, + const isCompleted = percentage >= 0.98; + + // Build R2Progression + const progression: R2Progression = { + device: { id: CODEX_DEVICE_ID, name: CODEX_DEVICE_NAME }, + locator: { + href, + locations: { + position: currentPage, + totalProgression: percentage, + cfi, + }, + type: "application/xhtml+xml", + }, + modified: new Date().toISOString(), + }; + + // Save both in parallel + Promise.all([ + readProgressApi.update(currentBookId, { + currentPage, progressPercentage: percentage, completed: isCompleted, - }) + }), + readProgressApi.updateProgression(currentBookId, progression), + ]) .then(() => { lastSavedPercentageRef.current = percentage; - // Invalidate related queries queryClient.invalidateQueries({ queryKey: ["readProgress", currentBookId], }); - queryClient.invalidateQueries({ queryKey: ["book", currentBookId] }); - queryClient.invalidateQueries({ queryKey: ["books", "in-progress"] }); + queryClient.invalidateQueries({ + queryKey: ["progression", currentBookId], + }); + queryClient.invalidateQueries({ + queryKey: ["book", currentBookId], + }); + queryClient.invalidateQueries({ + queryKey: ["books", "in-progress"], + }); queryClient.invalidateQueries({ queryKey: ["books", "recently-read"], }); @@ -157,22 +201,22 @@ export function useEpubProgress({ // Debounced save - public API const saveLocation = useCallback( - (cfi: string, percentage: number) => { + (cfi: string, percentage: number, href: string) => { if (!enabled) return; // Store pending values for flush on unmount pendingCfiRef.current = cfi; pendingPercentageRef.current = percentage; + pendingHrefRef.current = href; // Skip CFI save if same as last saved const cfiChanged = cfi !== lastSavedCfiRef.current; - // Check if percentage changed significantly (avoids saving tiny changes) + // Check if percentage changed significantly const percentageChanged = Math.abs(percentage - lastSavedPercentageRef.current) > PERCENTAGE_CHANGE_THRESHOLD; - // Skip if nothing changed if (!cfiChanged && !percentageChanged) { return; } @@ -182,22 +226,22 @@ export function useEpubProgress({ clearTimeout(debounceTimerRef.current); } - // Set new debounced save - // Capture values now to avoid stale closures const shouldSaveCfi = cfiChanged; const shouldSavePercentage = percentageChanged; const cfiToSave = cfi; const percentageToSave = percentage; + const hrefToSave = href; debounceTimerRef.current = setTimeout(() => { if (shouldSaveCfi) { saveToStorage(cfiToSave); } if (shouldSavePercentage) { - saveToBackend(percentageToSave); + saveToBackend(percentageToSave, cfiToSave, hrefToSave); } pendingCfiRef.current = null; pendingPercentageRef.current = 0; + pendingHrefRef.current = ""; }, debounceMs); }, [enabled, debounceMs, saveToStorage, saveToBackend], @@ -222,7 +266,6 @@ export function useEpubProgress({ // Cleanup: save pending progress on unmount useEffect(() => { - // Capture current values for cleanup const currentStorageKey = storageKey; return () => { @@ -230,7 +273,6 @@ export function useEpubProgress({ clearTimeout(debounceTimerRef.current); } - // Skip saving if tracking is disabled (incognito mode) if (!enabledRef.current) { return; } @@ -246,30 +288,49 @@ export function useEpubProgress({ // Ignore errors on unmount } } + // Flush any pending percentage to backend if (pendingPercentageRef.current >= 0 && pendingCfiRef.current) { const currentBookId = bookIdRef.current; const currentTotalPages = totalPagesRef.current; const percentage = pendingPercentageRef.current; + const cfi = pendingCfiRef.current; + const href = pendingHrefRef.current; const currentPage = currentTotalPages > 0 ? Math.max(1, Math.round(percentage * currentTotalPages)) : Math.max(1, Math.round(percentage * 100)); - // Check if percentage changed significantly if ( Math.abs(percentage - lastSavedPercentageRef.current) > PERCENTAGE_CHANGE_THRESHOLD ) { + const isCompleted = percentage >= 0.98; + + // Save both legacy progress and R2Progression on unmount readProgressApi .update(currentBookId, { - currentPage: currentPage, + currentPage, progressPercentage: percentage, - completed: percentage >= 0.98, + completed: isCompleted, + }) + .catch(() => {}); + + readProgressApi + .updateProgression(currentBookId, { + device: { id: CODEX_DEVICE_ID, name: CODEX_DEVICE_NAME }, + locator: { + href, + locations: { + position: currentPage, + totalProgression: percentage, + cfi, + }, + type: "application/xhtml+xml", + }, + modified: new Date().toISOString(), }) - .catch(() => { - // Ignore errors on unmount - }); + .catch(() => {}); } } }; @@ -278,7 +339,8 @@ export function useEpubProgress({ return { getSavedLocation, initialPercentage, - isLoadingProgress, + initialCfi, + isLoadingProgress: isLoadingProgress || isLoadingProgression, saveLocation, clearProgress, }; From 57417683309f5d520f1b521eb9647f14e5099b27 Mon Sep 17 00:00:00 2001 From: Sylvain Cau Date: Sat, 14 Mar 2026 19:56:28 -0700 Subject: [PATCH 2/9] fix(epub): fix page count, progress sync, and cross-app compatibility Fix EPUB parser failing to detect namespace-prefixed OPF tags (e.g., , ), which caused incorrect spine/page counts. Add helpers that match both bare and prefixed XML tag names, with tests. Fix cross-app sync with Readium-based readers (e.g., Komic) by resolving epub.js relative hrefs to full EPUB-internal paths when saving R2Progression locators (e.g., "contents.xhtml" -> "OEBPS/contents.xhtml"). Fix progress display on BookDetail and MediaCard to prefer R2Progression's totalProgression over the misleading currentPage/pageCount ratio for EPUBs. Remove dead startPercent URL parameter code from the EPUB reader, since position is now restored from R2Progression CFI automatically. Remove percentage change threshold from EPUB progress saving so every page turn triggers a save (still debounced at 1s), matching the behavior of comic/PDF readers. --- src/parsers/epub/parser.rs | 294 ++++++++++++++++-- web/src/components/library/MediaCard.tsx | 10 +- web/src/components/reader/EpubReader.test.tsx | 23 -- web/src/components/reader/EpubReader.tsx | 66 +--- web/src/components/reader/ReaderRouter.tsx | 4 - .../reader/hooks/useEpubProgress.ts | 89 ++---- web/src/pages/BookDetail.tsx | 26 +- web/src/pages/Reader.tsx | 8 - 8 files changed, 337 insertions(+), 183 deletions(-) diff --git a/src/parsers/epub/parser.rs b/src/parsers/epub/parser.rs index d80668ad..55d3afe2 100644 --- a/src/parsers/epub/parser.rs +++ b/src/parsers/epub/parser.rs @@ -14,6 +14,73 @@ use zip::ZipArchive; pub struct EpubParser; +/// Find the next occurrence of an XML tag, handling optional namespace prefixes. +/// For example, searching for "item" will match both `(haystack: &'a str, local_name: &str) -> Option<(usize, &'a str)> { + let bare_space = format!("<{} ", local_name); + let bare_gt = format!("<{}>", local_name); + let mut search_from = 0; + while search_from < haystack.len() { + let remaining = &haystack[search_from..]; + // Try bare tag first: `` (no attributes) + if let Some(pos) = remaining + .find(bare_space.as_str()) + .or_else(|| remaining.find(bare_gt.as_str())) + { + return Some((search_from + pos, &haystack[search_from + pos..])); + } + // Try namespace-prefixed: look for `: ` or `:` preceded by `<` and a prefix + let prefixed_suffix = format!(":{}", local_name); + if let Some(colon_pos) = remaining.find(prefixed_suffix.as_str()) { + // Check the character after the local_name to ensure it's a complete tag name + let after_pos = colon_pos + prefixed_suffix.len(); + if after_pos < remaining.len() { + let next_char = remaining.as_bytes()[after_pos]; + if next_char == b' ' || next_char == b'>' || next_char == b'/' { + // Walk backwards from colon to find `<` + let before_colon = &remaining[..colon_pos]; + if let Some(lt_pos) = before_colon.rfind('<') { + // Verify the prefix between `<` and `:` is a valid XML name (no spaces) + let prefix = &before_colon[lt_pos + 1..]; + if !prefix.is_empty() && !prefix.contains(' ') && !prefix.contains('>') { + let abs_pos = search_from + lt_pos; + return Some((abs_pos, &haystack[abs_pos..])); + } + } + } + } + search_from += colon_pos + prefixed_suffix.len(); + } else { + break; + } + } + None +} + +/// Find the closing tag for an XML element, handling optional namespace prefixes. +/// For example, searching for "spine" will match both `` and ``. +fn find_xml_closing_tag(haystack: &str, local_name: &str) -> Option { + let bare = format!("", local_name); + if let Some(pos) = haystack.find(bare.as_str()) { + return Some(pos); + } + // Try namespace-prefixed closing tags + let suffix = format!(":{}>", local_name); + if let Some(suffix_pos) = haystack.find(suffix.as_str()) { + // Walk backwards to find `') { + return Some(lt_pos); + } + } + } + None +} + impl EpubParser { pub fn new() -> Self { Self @@ -110,10 +177,9 @@ impl EpubParser { // Parse manifest to get id -> href mapping let mut manifest: HashMap = HashMap::new(); - // Simple XML parsing for manifest items + // Simple XML parsing for manifest items (handles both and ) let mut remaining = &xml_content[..]; - while let Some(item_start) = remaining.find("') { let item_tag = &item_section[..item_end]; @@ -150,37 +216,36 @@ impl EpubParser { } // Parse spine to get reading order (idref list) + // Handles both and , and let mut spine_order: Vec = Vec::new(); remaining = &xml_content[..]; - if let Some(spine_start) = remaining.find("") { - let spine_content = &spine_section[..spine_end]; - - // Extract itemrefs - let mut itemref_remaining = spine_content; - while let Some(itemref_start) = itemref_remaining.find("') { - let itemref_tag = &itemref_section[..itemref_end]; - - // Extract idref - if let Some(idref_start) = itemref_tag.find("idref=\"") { - let idref_value_start = idref_start + 7; - if let Some(idref_end) = itemref_tag[idref_value_start..].find('"') { - let idref = - &itemref_tag[idref_value_start..idref_value_start + idref_end]; - if let Some(path) = manifest.get(idref) { - spine_order.push(path.clone()); - } + if let Some((_pos, spine_section)) = find_xml_tag(remaining, "spine") + && let Some(spine_end) = find_xml_closing_tag(spine_section, "spine") + { + let spine_content = &spine_section[..spine_end]; + + // Extract itemrefs + let mut itemref_remaining = spine_content; + while let Some((_pos, itemref_section)) = find_xml_tag(itemref_remaining, "itemref") { + if let Some(itemref_end) = itemref_section.find('>') { + let itemref_tag = &itemref_section[..itemref_end]; + + // Extract idref + if let Some(idref_start) = itemref_tag.find("idref=\"") { + let idref_value_start = idref_start + 7; + if let Some(idref_end) = itemref_tag[idref_value_start..].find('"') { + let idref = + &itemref_tag[idref_value_start..idref_value_start + idref_end]; + if let Some(path) = manifest.get(idref) { + spine_order.push(path.clone()); } } - - itemref_remaining = &itemref_section[itemref_end..]; - } else { - break; } + + itemref_remaining = &itemref_section[itemref_end..]; + } else { + break; } } } @@ -375,12 +440,12 @@ fn find_cover_image_from_opf(archive: &mut ZipArchive) -> Option { }; // Build a map of manifest item IDs to hrefs + // Handles both and namespace-prefixed tags let mut manifest_items: std::collections::HashMap = std::collections::HashMap::new(); let mut remaining = &opf_content[..]; - while let Some(item_start) = remaining.find("') { let item_tag = &item_section[..item_end]; @@ -879,4 +944,173 @@ mod tests { assert_eq!(isbns.len(), 1); assert_eq!(isbns[0], "9780306406157"); } + + #[test] + fn test_find_xml_tag_bare() { + let xml = r#""#; + let result = find_xml_tag(xml, "item"); + assert!(result.is_some()); + let (pos, _section) = result.unwrap(); + assert_eq!(pos, 0); + } + + #[test] + fn test_find_xml_tag_namespaced() { + let xml = r#""#; + let result = find_xml_tag(xml, "item"); + assert!(result.is_some()); + let (pos, _section) = result.unwrap(); + assert_eq!(pos, 0); + } + + #[test] + fn test_find_xml_tag_no_match() { + let xml = r#""#; + let result = find_xml_tag(xml, "item"); + assert!(result.is_none()); + } + + #[test] + fn test_find_xml_closing_tag_bare() { + let xml = r#""#; + let result = find_xml_closing_tag(xml, "spine"); + assert!(result.is_some()); + assert_eq!(&xml[result.unwrap()..], ""); + } + + #[test] + fn test_find_xml_closing_tag_namespaced() { + let xml = r#""#; + let result = find_xml_closing_tag(xml, "spine"); + assert!(result.is_some()); + assert_eq!(&xml[result.unwrap()..], ""); + } + + #[test] + fn test_parse_opf_with_namespace_prefixed_tags() { + // Create a minimal EPUB with namespace-prefixed OPF tags + use std::io::Write; + let temp_dir = tempfile::tempdir().unwrap(); + let epub_path = temp_dir.path().join("test.epub"); + + let mut zip = zip::ZipWriter::new(File::create(&epub_path).unwrap()); + + // mimetype + let options = zip::write::SimpleFileOptions::default() + .compression_method(zip::CompressionMethod::Stored); + zip.start_file("mimetype", options).unwrap(); + zip.write_all(b"application/epub+zip").unwrap(); + + // container.xml + let options = zip::write::SimpleFileOptions::default(); + zip.start_file("META-INF/container.xml", options).unwrap(); + zip.write_all( + br#" + + + + +"#, + ) + .unwrap(); + + // OPF with opf: namespace prefix (like the Merlin EPUB) + zip.start_file("OEBPS/content.opf", options).unwrap(); + zip.write_all(br#" + + + Test Book + Test Author + + + + + + + + + + + +"#).unwrap(); + + // Create dummy XHTML files + for name in &["OEBPS/ch1.xhtml", "OEBPS/ch2.xhtml", "OEBPS/ch3.xhtml"] { + zip.start_file(*name, options).unwrap(); + zip.write_all(b"

Content

") + .unwrap(); + } + + zip.finish().unwrap(); + + // Parse and verify + let parser = EpubParser::new(); + let metadata = parser.parse(&epub_path).unwrap(); + // Should find 3 spine items (not fall back to 0 due to namespace issues) + assert_eq!( + metadata.page_count, 3, + "Should parse 3 spine items from namespace-prefixed OPF" + ); + } + + #[test] + fn test_parse_opf_without_namespace_prefix() { + // Verify bare tags still work + use std::io::Write; + let temp_dir = tempfile::tempdir().unwrap(); + let epub_path = temp_dir.path().join("test.epub"); + + let mut zip = zip::ZipWriter::new(File::create(&epub_path).unwrap()); + + let options = zip::write::SimpleFileOptions::default() + .compression_method(zip::CompressionMethod::Stored); + zip.start_file("mimetype", options).unwrap(); + zip.write_all(b"application/epub+zip").unwrap(); + + let options = zip::write::SimpleFileOptions::default(); + zip.start_file("META-INF/container.xml", options).unwrap(); + zip.write_all( + br#" + + + + +"#, + ) + .unwrap(); + + zip.start_file("content.opf", options).unwrap(); + zip.write_all( + br#" + + + Test + + + + + + + + + +"#, + ) + .unwrap(); + + for name in &["ch1.xhtml", "ch2.xhtml"] { + zip.start_file(*name, options).unwrap(); + zip.write_all(b"

Content

") + .unwrap(); + } + + zip.finish().unwrap(); + + let parser = EpubParser::new(); + let metadata = parser.parse(&epub_path).unwrap(); + assert_eq!( + metadata.page_count, 2, + "Should parse 2 spine items from bare OPF tags" + ); + } } diff --git a/web/src/components/library/MediaCard.tsx b/web/src/components/library/MediaCard.tsx index 19680d28..cfffe38f 100644 --- a/web/src/components/library/MediaCard.tsx +++ b/web/src/components/library/MediaCard.tsx @@ -155,10 +155,14 @@ export const MediaCard = memo(function MediaCard({ }; // Calculate progress percentage for books + // Prefer progressPercentage (from R2Progression) for EPUBs where page_count + // is spine items, not actual pages. const progressPercentage = - book?.readProgress && book.pageCount - ? (book.readProgress.currentPage / book.pageCount) * 100 - : 0; + book?.readProgress?.progressPercentage != null + ? book.readProgress.progressPercentage * 100 + : book?.readProgress && book.pageCount + ? (book.readProgress.currentPage / book.pageCount) * 100 + : 0; // Book analysis mutation const bookAnalyzeMutation = useMutation({ diff --git a/web/src/components/reader/EpubReader.test.tsx b/web/src/components/reader/EpubReader.test.tsx index ed20ba2f..ffde0547 100644 --- a/web/src/components/reader/EpubReader.test.tsx +++ b/web/src/components/reader/EpubReader.test.tsx @@ -280,29 +280,6 @@ describe("EpubReader", () => { }); }); - describe("URL parameters", () => { - it("should handle startPercent parameter", () => { - renderWithProviders(); - - // Reader should render with the start percent - expect(screen.getByTestId("react-reader-mock")).toBeInTheDocument(); - }); - - it("should ignore invalid startPercent (negative)", () => { - renderWithProviders(); - - // Should still render without error - expect(screen.getByTestId("react-reader-mock")).toBeInTheDocument(); - }); - - it("should ignore invalid startPercent (greater than 1)", () => { - renderWithProviders(); - - // Should still render without error - expect(screen.getByTestId("react-reader-mock")).toBeInTheDocument(); - }); - }); - describe("fullscreen", () => { it("should not be fullscreen by default", () => { renderWithProviders(); diff --git a/web/src/components/reader/EpubReader.tsx b/web/src/components/reader/EpubReader.tsx index ae049ac8..0ea5a152 100644 --- a/web/src/components/reader/EpubReader.tsx +++ b/web/src/components/reader/EpubReader.tsx @@ -134,8 +134,6 @@ interface EpubReaderProps { title: string; /** Total pages in the book (for progress calculation) */ totalPages: number; - /** Starting percentage from URL parameter (0.0-1.0, overrides saved progress) */ - startPercent?: number; /** Incognito mode - when true, progress tracking is disabled */ incognito?: boolean; /** Callback when reader should close */ @@ -158,7 +156,6 @@ export function EpubReader({ seriesId, title, totalPages, - startPercent, incognito, onClose, }: EpubReaderProps) { @@ -229,12 +226,7 @@ export function EpubReader({ totalPagesRef.current = totalPages; // Local state - initialize with saved CFI location from localStorage - // Note: startPercent from URL is handled after locations are generated const [location, setLocation] = useState(() => { - // If startPercent is provided, don't load from localStorage - we'll navigate after locations are ready - if (startPercent != null && startPercent >= 0 && startPercent <= 1) { - return 0; // Start at 0, will navigate to startPercent after locations are generated - } const saved = getSavedLocation(); if (saved) { initialLocationLoadedRef.current = true; @@ -242,7 +234,6 @@ export function EpubReader({ } return 0; }); - const [hasAppliedStartPercent, setHasAppliedStartPercent] = useState(false); const [hasAppliedApiProgress, setHasAppliedApiProgress] = useState(false); const [locationsReady, setLocationsReady] = useState(false); const [isLoading, setIsLoading] = useState(true); @@ -301,11 +292,6 @@ export function EpubReader({ // Generate EPUB file URL const epubUrl = `/api/v1/books/${bookId}/file`; - // Track if we need to wait for startPercent navigation before showing content - const needsStartPercentNavigation = - startPercent != null && startPercent >= 0 && startPercent <= 1; - const startPercentAppliedRef = useRef(!needsStartPercentNavigation); - // Handle location change (CFI-based progress) // Note: Progress is saved in the 'relocated' event handler below, // where we have access to the accurate percentage value @@ -354,32 +340,7 @@ export function EpubReader({ } }, [epubMargin]); - // Apply startPercent from URL (highest priority - overrides saved progress) - useEffect(() => { - if ( - locationsReady && - !hasAppliedStartPercent && - startPercent != null && - startPercent >= 0 && - startPercent <= 1 && - renditionRef.current - ) { - // Navigate directly to percentage - const book = renditionRef.current.book; - if (book?.locations?.length()) { - const cfi = book.locations.cfiFromPercentage(startPercent); - if (cfi) { - setLocation(cfi); - } - } - setHasAppliedStartPercent(true); - startPercentAppliedRef.current = true; - // Clear loading now that we've navigated to the correct position - setIsLoading(false); - } - }, [locationsReady, hasAppliedStartPercent, startPercent]); - - // Apply API progress for cross-device sync (only if no localStorage CFI and no startPercent) + // Apply API progress for cross-device sync (only if no localStorage CFI) // Priority: initialCfi (from R2Progression, precise) > initialPercentage (approximate) useEffect(() => { if ( @@ -387,10 +348,8 @@ export function EpubReader({ !isLoadingProgress && (initialCfi !== null || initialPercentage !== null) && !hasAppliedApiProgress && - !hasAppliedStartPercent && !initialLocationLoadedRef.current && - renditionRef.current && - startPercent == null // Don't apply API progress if startPercent is provided + renditionRef.current ) { if (initialCfi) { // Use precise CFI from R2Progression (saved by Codex web on another device) @@ -413,8 +372,6 @@ export function EpubReader({ initialCfi, initialPercentage, hasAppliedApiProgress, - hasAppliedStartPercent, - startPercent, ]); // Ref for onClose to keep handleGetRendition stable @@ -472,10 +429,7 @@ export function EpubReader({ // Track current chapter for TOC highlighting and save progress rendition.on("relocated", (location: Location) => { setCurrentHref(location.start.href); - // Only clear loading if we don't need to wait for startPercent navigation - if (startPercentAppliedRef.current) { - setIsLoading(false); - } + setIsLoading(false); // Get percentage from book locations using the CFI const cfi = location.start.cfi; @@ -519,7 +473,19 @@ export function EpubReader({ // Save progress - the hook handles debouncing and duplicate detection // Note: percentage can be 0 at the start of the book, which is valid - saveLocationRef.current(cfi, percentage, location.start.href); + // Resolve href to full EPUB-internal path (e.g., "OEBPS/chapter1.xhtml") + // epub.js returns href relative to the OPF directory, but Readium-based + // apps (like Komic) expect the full path within the EPUB archive. + const bookDir = + (rendition.book.path as { directory?: string })?.directory ?? ""; + const stripped = bookDir === "/" ? "" : bookDir; + const normalizedDir = stripped.startsWith("/") + ? stripped.slice(1) + : stripped; + const fullHref = normalizedDir + ? `${normalizedDir}${location.start.href}` + : location.start.href; + saveLocationRef.current(cfi, percentage, fullHref); }); }, []); diff --git a/web/src/components/reader/ReaderRouter.tsx b/web/src/components/reader/ReaderRouter.tsx index e9301c69..c4d61d6d 100644 --- a/web/src/components/reader/ReaderRouter.tsx +++ b/web/src/components/reader/ReaderRouter.tsx @@ -26,8 +26,6 @@ interface ReaderRouterProps { analyzed?: boolean; /** Starting page from URL parameter (overrides saved progress) - for comics/PDFs */ startPage?: number; - /** Starting percentage from URL parameter (0.0-1.0) - for EPUBs */ - startPercent?: number; /** Incognito mode - when true, progress tracking is disabled */ incognito?: boolean; /** Callback when reader should close */ @@ -52,7 +50,6 @@ export function ReaderRouter({ readingDirection, analyzed, startPage, - startPercent, incognito, onClose, }: ReaderRouterProps) { @@ -143,7 +140,6 @@ export function ReaderRouter({ seriesId={seriesId} title={title} totalPages={totalPages} - startPercent={startPercent} incognito={incognito} onClose={onClose} /> diff --git a/web/src/components/reader/hooks/useEpubProgress.ts b/web/src/components/reader/hooks/useEpubProgress.ts index f15fd5bd..3f3af5dd 100644 --- a/web/src/components/reader/hooks/useEpubProgress.ts +++ b/web/src/components/reader/hooks/useEpubProgress.ts @@ -5,7 +5,6 @@ import { type R2Progression, readProgressApi } from "@/api/readProgress"; const STORAGE_KEY_PREFIX = "epub-cfi-"; // Threshold for considering percentage as "changed" (avoids saving tiny changes) -const PERCENTAGE_CHANGE_THRESHOLD = 0.005; // 0.5% const CODEX_DEVICE_ID = "codex-web"; const CODEX_DEVICE_NAME = "Codex Web Reader"; @@ -54,7 +53,6 @@ export function useEpubProgress({ const queryClient = useQueryClient(); const debounceTimerRef = useRef(null); const lastSavedCfiRef = useRef(null); - const lastSavedPercentageRef = useRef(0); const pendingCfiRef = useRef(null); const pendingPercentageRef = useRef(0); const pendingHrefRef = useRef(""); @@ -99,17 +97,6 @@ export function useEpubProgress({ totalPagesRef.current = totalPages; }, [bookId, totalPages]); - // Initialize lastSavedPercentageRef from API progress to avoid duplicate saves - useEffect(() => { - if (r2Progression?.locator?.locations?.totalProgression != null) { - lastSavedPercentageRef.current = - r2Progression.locator.locations.totalProgression; - } else if (progressWithPercentage?.progress_percentage != null) { - lastSavedPercentageRef.current = - progressWithPercentage.progress_percentage; - } - }, [r2Progression, progressWithPercentage]); - const storageKey = `${STORAGE_KEY_PREFIX}${bookId}`; // Get saved location from localStorage @@ -175,7 +162,6 @@ export function useEpubProgress({ readProgressApi.updateProgression(currentBookId, progression), ]) .then(() => { - lastSavedPercentageRef.current = percentage; queryClient.invalidateQueries({ queryKey: ["readProgress", currentBookId], }); @@ -209,15 +195,9 @@ export function useEpubProgress({ pendingPercentageRef.current = percentage; pendingHrefRef.current = href; - // Skip CFI save if same as last saved + // Skip if nothing changed const cfiChanged = cfi !== lastSavedCfiRef.current; - - // Check if percentage changed significantly - const percentageChanged = - Math.abs(percentage - lastSavedPercentageRef.current) > - PERCENTAGE_CHANGE_THRESHOLD; - - if (!cfiChanged && !percentageChanged) { + if (!cfiChanged) { return; } @@ -226,19 +206,13 @@ export function useEpubProgress({ clearTimeout(debounceTimerRef.current); } - const shouldSaveCfi = cfiChanged; - const shouldSavePercentage = percentageChanged; const cfiToSave = cfi; const percentageToSave = percentage; const hrefToSave = href; debounceTimerRef.current = setTimeout(() => { - if (shouldSaveCfi) { - saveToStorage(cfiToSave); - } - if (shouldSavePercentage) { - saveToBackend(percentageToSave, cfiToSave, hrefToSave); - } + saveToStorage(cfiToSave); + saveToBackend(percentageToSave, cfiToSave, hrefToSave); pendingCfiRef.current = null; pendingPercentageRef.current = 0; pendingHrefRef.current = ""; @@ -301,37 +275,32 @@ export function useEpubProgress({ ? Math.max(1, Math.round(percentage * currentTotalPages)) : Math.max(1, Math.round(percentage * 100)); - if ( - Math.abs(percentage - lastSavedPercentageRef.current) > - PERCENTAGE_CHANGE_THRESHOLD - ) { - const isCompleted = percentage >= 0.98; - - // Save both legacy progress and R2Progression on unmount - readProgressApi - .update(currentBookId, { - currentPage, - progressPercentage: percentage, - completed: isCompleted, - }) - .catch(() => {}); - - readProgressApi - .updateProgression(currentBookId, { - device: { id: CODEX_DEVICE_ID, name: CODEX_DEVICE_NAME }, - locator: { - href, - locations: { - position: currentPage, - totalProgression: percentage, - cfi, - }, - type: "application/xhtml+xml", + const isCompleted = percentage >= 0.98; + + // Save both legacy progress and R2Progression on unmount + readProgressApi + .update(currentBookId, { + currentPage, + progressPercentage: percentage, + completed: isCompleted, + }) + .catch(() => {}); + + readProgressApi + .updateProgression(currentBookId, { + device: { id: CODEX_DEVICE_ID, name: CODEX_DEVICE_NAME }, + locator: { + href, + locations: { + position: currentPage, + totalProgression: percentage, + cfi, }, - modified: new Date().toISOString(), - }) - .catch(() => {}); - } + type: "application/xhtml+xml", + }, + modified: new Date().toISOString(), + }) + .catch(() => {}); } }; }, [storageKey]); diff --git a/web/src/pages/BookDetail.tsx b/web/src/pages/BookDetail.tsx index bcff7824..66d25438 100644 --- a/web/src/pages/BookDetail.tsx +++ b/web/src/pages/BookDetail.tsx @@ -387,10 +387,17 @@ export function BookDetail() { { title: displayTitle, href: `/books/${book.id}` }, ]; - // Calculate reading progress (current_page is 1-indexed) + // Calculate reading progress + // For EPUBs, prefer progressPercentage (from totalProgression) since page_count + // is just spine items and doesn't represent actual pages. + // For other formats, use currentPage / pageCount. const currentPage = book.readProgress ? book.readProgress.currentPage : 0; const percentage = - book.pageCount > 0 ? (currentPage / book.pageCount) * 100 : 0; + book.readProgress?.progressPercentage != null + ? book.readProgress.progressPercentage * 100 + : book.pageCount > 0 + ? (currentPage / book.pageCount) * 100 + : 0; // Extract metadata values const languageDisplay = metadata?.languageIso @@ -641,8 +648,13 @@ export function BookDetail() { variant="filled" leftSection={} onClick={() => { - const page = book.readProgress?.currentPage ?? 1; - navigate(`/reader/${book.id}?page=${page}`); + if (book.fileFormat === "epub") { + // EPUB reader restores position from R2Progression CFI automatically + navigate(`/reader/${book.id}`); + } else { + const page = book.readProgress?.currentPage ?? 1; + navigate(`/reader/${book.id}?page=${page}`); + } }} > {hasProgress && !isCompleted ? "Continue" : "Read"} @@ -653,7 +665,11 @@ export function BookDetail() { variant="outline" leftSection={} onClick={() => - navigate(`/reader/${book.id}?page=1&incognito=true`) + navigate( + book.fileFormat === "epub" + ? `/reader/${book.id}?incognito=true` + : `/reader/${book.id}?page=1&incognito=true`, + ) } > Incognito diff --git a/web/src/pages/Reader.tsx b/web/src/pages/Reader.tsx index d8259c5d..1dca86c7 100644 --- a/web/src/pages/Reader.tsx +++ b/web/src/pages/Reader.tsx @@ -20,13 +20,6 @@ export function Reader() { const pageParam = searchParams.get("page"); const startPage = pageParam ? Number.parseInt(pageParam, 10) : undefined; - // Extract percent query parameter (e.g., ?percent=45) for EPUBs - // Accepts 0-100 and converts to 0.0-1.0 - const percentParam = searchParams.get("percent"); - const startPercent = percentParam - ? Number.parseFloat(percentParam) / 100 - : undefined; - // Extract incognito parameter (e.g., ?incognito=true) for reading without progress tracking const incognito = searchParams.get("incognito") === "true"; @@ -92,7 +85,6 @@ export function Reader() { readingDirection={book.readingDirection ?? null} analyzed={book.analyzed} startPage={startPage} - startPercent={startPercent} incognito={incognito} onClose={handleClose} /> From a4661e6249d20964d5d6c2459f6636dfde8214ee Mon Sep 17 00:00:00 2001 From: Sylvain Cau Date: Sat, 14 Mar 2026 21:01:36 -0700 Subject: [PATCH 3/9] fix(reader): prefer API progress over localStorage when newer for cross-device sync Previously, the EPUB reader always used the localStorage CFI on load, ignoring any newer progress from the API (e.g., updated via Komic or another device). This meant cross-device sync effectively never worked when the user had previously opened the same book on the current device. Now localStorage saves a timestamp alongside the CFI, and on load the reader compares it with the R2Progression `modified` timestamp from the API. Whichever is newer wins, so progress updated on another app or device is correctly restored. --- web/src/components/reader/EpubReader.tsx | 50 ++++++++++++++----- .../reader/hooks/useEpubProgress.ts | 28 ++++++++++- 2 files changed, 64 insertions(+), 14 deletions(-) diff --git a/web/src/components/reader/EpubReader.tsx b/web/src/components/reader/EpubReader.tsx index 0ea5a152..9a6a7ebc 100644 --- a/web/src/components/reader/EpubReader.tsx +++ b/web/src/components/reader/EpubReader.tsx @@ -168,8 +168,10 @@ export function EpubReader({ // CFI-based progress tracking (also syncs to backend, disabled in incognito mode) const { getSavedLocation, + getLocalTimestamp, initialPercentage, initialCfi, + apiTimestamp, isLoadingProgress, saveLocation, } = useEpubProgress({ @@ -226,6 +228,8 @@ export function EpubReader({ totalPagesRef.current = totalPages; // Local state - initialize with saved CFI location from localStorage + // Note: This provides instant restore, but the cross-device sync effect + // below may override it if the API has newer progress. const [location, setLocation] = useState(() => { const saved = getSavedLocation(); if (saved) { @@ -340,27 +344,45 @@ export function EpubReader({ } }, [epubMargin]); - // Apply API progress for cross-device sync (only if no localStorage CFI) - // Priority: initialCfi (from R2Progression, precise) > initialPercentage (approximate) + // Apply API progress for cross-device sync. + // Compares localStorage timestamp with API (R2Progression) timestamp. + // If API is newer (e.g., progress updated from another device/app), use API data. + // If localStorage is newer or no API data, keep the localStorage position. + // Priority: initialCfi (precise) > initialPercentage (approximate) useEffect(() => { if ( locationsReady && !isLoadingProgress && (initialCfi !== null || initialPercentage !== null) && !hasAppliedApiProgress && - !initialLocationLoadedRef.current && renditionRef.current ) { - if (initialCfi) { - // Use precise CFI from R2Progression (saved by Codex web on another device) - setLocation(initialCfi); - } else if (initialPercentage !== null) { - // Fall back to percentage-based location (from Komic or legacy progress) - const book = renditionRef.current.book; - if (book?.locations?.length()) { - const cfi = book.locations.cfiFromPercentage(initialPercentage); - if (cfi) { - setLocation(cfi); + // Check if API data is newer than localStorage + let shouldApplyApi = !initialLocationLoadedRef.current; // No local data, always apply + + if (initialLocationLoadedRef.current && apiTimestamp) { + // Both local and API data exist; compare timestamps + const localTs = getLocalTimestamp(); + if (!localTs) { + // No local timestamp (old data before timestamps were stored), prefer API + shouldApplyApi = true; + } else { + const localTime = new Date(localTs).getTime(); + const apiTime = new Date(apiTimestamp).getTime(); + shouldApplyApi = apiTime > localTime; + } + } + + if (shouldApplyApi) { + if (initialCfi) { + setLocation(initialCfi); + } else if (initialPercentage !== null) { + const book = renditionRef.current.book; + if (book?.locations?.length()) { + const cfi = book.locations.cfiFromPercentage(initialPercentage); + if (cfi) { + setLocation(cfi); + } } } } @@ -371,7 +393,9 @@ export function EpubReader({ isLoadingProgress, initialCfi, initialPercentage, + apiTimestamp, hasAppliedApiProgress, + getLocalTimestamp, ]); // Ref for onClose to keep handleGetRendition stable diff --git a/web/src/components/reader/hooks/useEpubProgress.ts b/web/src/components/reader/hooks/useEpubProgress.ts index 3f3af5dd..04b5c971 100644 --- a/web/src/components/reader/hooks/useEpubProgress.ts +++ b/web/src/components/reader/hooks/useEpubProgress.ts @@ -3,6 +3,7 @@ import { useCallback, useEffect, useRef } from "react"; import { type R2Progression, readProgressApi } from "@/api/readProgress"; const STORAGE_KEY_PREFIX = "epub-cfi-"; +const STORAGE_TIMESTAMP_PREFIX = "epub-cfi-ts-"; // Threshold for considering percentage as "changed" (avoids saving tiny changes) @@ -23,10 +24,14 @@ interface UseEpubProgressOptions { interface UseEpubProgressReturn { /** Get the saved CFI location for this book (null if none saved) */ getSavedLocation: () => string | null; + /** Get the localStorage timestamp for this book (null if none saved) */ + getLocalTimestamp: () => string | null; /** Get the initial percentage from API (for cross-device sync) */ initialPercentage: number | null; /** Get the initial CFI from R2Progression (for cross-device sync with Codex web) */ initialCfi: string | null; + /** Get the R2Progression modified timestamp (for cross-device sync) */ + apiTimestamp: string | null; /** Whether API progress is still loading */ isLoadingProgress: boolean; /** Save the current CFI location, percentage, and chapter href */ @@ -88,6 +93,9 @@ export function useEpubProgress({ // Get CFI from R2Progression if it was saved by Codex web (has cfi extension) const initialCfi = r2Progression?.locator?.locations?.cfi ?? null; + // Get the R2Progression modified timestamp for cross-device comparison + const apiTimestamp = r2Progression?.modified ?? null; + // Store refs to avoid dependency issues const bookIdRef = useRef(bookId); const totalPagesRef = useRef(totalPages); @@ -98,6 +106,7 @@ export function useEpubProgress({ }, [bookId, totalPages]); const storageKey = `${STORAGE_KEY_PREFIX}${bookId}`; + const timestampKey = `${STORAGE_TIMESTAMP_PREFIX}${bookId}`; // Get saved location from localStorage const getSavedLocation = useCallback((): string | null => { @@ -110,18 +119,29 @@ export function useEpubProgress({ } }, [storageKey, enabled]); + // Get saved timestamp from localStorage + const getLocalTimestamp = useCallback((): string | null => { + if (!enabled) return null; + try { + return localStorage.getItem(timestampKey); + } catch { + return null; + } + }, [timestampKey, enabled]); + // Save location to localStorage (internal, immediate) const saveToStorage = useCallback( (cfi: string) => { if (!enabled) return; try { localStorage.setItem(storageKey, cfi); + localStorage.setItem(timestampKey, new Date().toISOString()); lastSavedCfiRef.current = cfi; } catch { console.warn("Failed to save EPUB progress to localStorage"); } }, - [storageKey, enabled], + [storageKey, timestampKey, enabled], ); // Save progress to backend API (both legacy progress and R2Progression) @@ -258,6 +278,10 @@ export function useEpubProgress({ ) { try { localStorage.setItem(currentStorageKey, pendingCfiRef.current); + localStorage.setItem( + `${STORAGE_TIMESTAMP_PREFIX}${bookIdRef.current}`, + new Date().toISOString(), + ); } catch { // Ignore errors on unmount } @@ -307,8 +331,10 @@ export function useEpubProgress({ return { getSavedLocation, + getLocalTimestamp, initialPercentage, initialCfi, + apiTimestamp, isLoadingProgress: isLoadingProgress || isLoadingProgression, saveLocation, clearProgress, From ee859cf7f3929b7a8e52e9d117eb032d9ade13c5 Mon Sep 17 00:00:00 2001 From: Sylvain Cau Date: Sat, 14 Mar 2026 22:03:49 -0700 Subject: [PATCH 4/9] feat(epub): add server-side Readium positions for cross-app progression sync Implement the Readium positions algorithm (1 position per 1024 bytes of each spine resource) to normalize totalProgression values from any client, matching Komga's approach. This fixes cross-app EPUB reading sync between Codex web (epub.js) and Readium-based readers like Komic, which calculate totalProgression differently. - Extend EPUB parser to extract spine item file sizes and media types - Add compute_epub_positions() and normalize_progression() functions - Add epub_positions column to books table with migration - Store positions as JSON during book analysis - Update both native API and Komga API PUT progression handlers to normalize client totalProgression against server positions before storing - Fall back to client values for books without positions (non-EPUB or not yet re-analyzed) - Add unit tests for positions computation and normalization logic --- migration/src/lib.rs | 5 + .../m20260315_000062_add_epub_positions.rs | 29 +++ .../routes/komga/handlers/read_progress.rs | 68 +++++- src/api/routes/v1/handlers/read_progress.rs | 62 ++++- src/db/entities/books.rs | 2 + src/db/repositories/book.rs | 5 + src/db/repositories/book_covers.rs | 1 + src/db/repositories/book_external_id.rs | 1 + src/db/repositories/book_external_links.rs | 2 + src/db/repositories/genre.rs | 1 + src/db/repositories/metadata.rs | 1 + src/db/repositories/metrics.rs | 3 + src/db/repositories/page.rs | 7 + src/db/repositories/read_progress.rs | 1 + src/db/repositories/series.rs | 4 + src/db/repositories/tag.rs | 1 + src/parsers/cbr/parser.rs | 1 + src/parsers/cbz/parser.rs | 1 + src/parsers/epub/parser.rs | 66 ++++-- src/parsers/metadata.rs | 215 ++++++++++++++++++ src/parsers/pdf/parser.rs | 1 + src/scanner/analyzer_queue.rs | 4 + src/scanner/library_scanner.rs | 1 + src/services/read_progress.rs | 1 + src/tasks/handlers/user_plugin_sync/tests.rs | 1 + tests/api/books.rs | 1 + tests/api/bulk_metadata.rs | 1 + tests/api/bulk_operations.rs | 1 + tests/api/covers.rs | 1 + tests/api/genres.rs | 1 + tests/api/metadata_locks.rs | 1 + tests/api/opds.rs | 1 + tests/api/opds2.rs | 1 + tests/api/pages.rs | 1 + tests/api/read_progress.rs | 1 + tests/api/series.rs | 1 + tests/api/tags.rs | 1 + tests/common/fixtures.rs | 2 + tests/db/postgres.rs | 2 + tests/scanner/book_analysis_metadata.rs | 8 + tests/scanner/force_analysis.rs | 1 + 41 files changed, 479 insertions(+), 30 deletions(-) create mode 100644 migration/src/m20260315_000062_add_epub_positions.rs diff --git a/migration/src/lib.rs b/migration/src/lib.rs index 9e8a19e5..0b826168 100644 --- a/migration/src/lib.rs +++ b/migration/src/lib.rs @@ -126,6 +126,9 @@ mod m20260309_000060_add_koreader_hash; // Add r2_progression column for Readium/OPDS 2.0 EPUB progress sync mod m20260314_000061_add_r2_progression; +// Add epub_positions column for Readium positions list (cross-app sync) +mod m20260315_000062_add_epub_positions; + pub struct Migrator; #[async_trait::async_trait] @@ -224,6 +227,8 @@ impl MigratorTrait for Migrator { Box::new(m20260309_000060_add_koreader_hash::Migration), // Add r2_progression for Readium EPUB progress sync Box::new(m20260314_000061_add_r2_progression::Migration), + // Add epub_positions for Readium positions list (cross-app sync) + Box::new(m20260315_000062_add_epub_positions::Migration), ] } } diff --git a/migration/src/m20260315_000062_add_epub_positions.rs b/migration/src/m20260315_000062_add_epub_positions.rs new file mode 100644 index 00000000..c0464f91 --- /dev/null +++ b/migration/src/m20260315_000062_add_epub_positions.rs @@ -0,0 +1,29 @@ +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> { + manager + .alter_table( + Table::alter() + .table(Alias::new("books")) + .add_column(ColumnDef::new(Alias::new("epub_positions")).text()) + .to_owned(), + ) + .await + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .alter_table( + Table::alter() + .table(Alias::new("books")) + .drop_column(Alias::new("epub_positions")) + .to_owned(), + ) + .await + } +} diff --git a/src/api/routes/komga/handlers/read_progress.rs b/src/api/routes/komga/handlers/read_progress.rs index b4deb5d4..54e71613 100644 --- a/src/api/routes/komga/handlers/read_progress.rs +++ b/src/api/routes/komga/handlers/read_progress.rs @@ -303,25 +303,77 @@ pub async fn put_progression( .map_err(|e| ApiError::Internal(format!("Failed to fetch book: {}", e)))? .ok_or_else(|| ApiError::NotFound("Book not found".to_string()))?; - // Extract totalProgression from the R2Progression locator - let total_progression = body + // Extract totalProgression and href from the R2Progression locator + let client_total_progression = body .get("locator") .and_then(|l| l.get("locations")) .and_then(|l| l.get("totalProgression")) .and_then(|v| v.as_f64()) .unwrap_or(0.0); - // Derive page and completion from totalProgression - let current_page = if book.page_count > 0 { - (total_progression * book.page_count as f64) - .round() - .max(1.0) as i32 + let client_href = body + .get("locator") + .and_then(|l| l.get("href")) + .and_then(|v| v.as_str()) + .unwrap_or(""); + + // 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) + { + if let Some((normalized, position)) = crate::parsers::normalize_progression( + &positions, + client_href, + client_total_progression, + ) { + (normalized, position) + } else { + // Fallback: no matching position found + let page = if book.page_count > 0 { + (client_total_progression * book.page_count as f64) + .round() + .max(1.0) as i32 + } else { + 1 + }; + (client_total_progression, page) + } + } else { + // Fallback: couldn't parse positions JSON + let page = if book.page_count > 0 { + (client_total_progression * book.page_count as f64) + .round() + .max(1.0) as i32 + } else { + 1 + }; + (client_total_progression, page) + } } else { - 1 + // No positions available, use client value directly + let page = if book.page_count > 0 { + (client_total_progression * book.page_count as f64) + .round() + .max(1.0) as i32 + } else { + 1 + }; + (client_total_progression, page) }; + let completed = total_progression >= 0.98 || (book.page_count > 0 && current_page >= book.page_count); + // Store the R2Progression with server-normalized totalProgression + let mut body = body; + if let Some(locator) = body.get_mut("locator") + && let Some(locations) = locator.get_mut("locations") + { + locations["totalProgression"] = serde_json::json!(total_progression); + locations["position"] = serde_json::json!(current_page); + } + let json_str = serde_json::to_string(&body) .map_err(|e| ApiError::Internal(format!("Failed to serialize R2Progression: {}", e)))?; diff --git a/src/api/routes/v1/handlers/read_progress.rs b/src/api/routes/v1/handlers/read_progress.rs index a1dc090e..77638f37 100644 --- a/src/api/routes/v1/handlers/read_progress.rs +++ b/src/api/routes/v1/handlers/read_progress.rs @@ -352,23 +352,73 @@ pub async fn put_progression( .map_err(|e| ApiError::Internal(format!("Failed to fetch book: {}", e)))? .ok_or_else(|| ApiError::NotFound("Book not found".to_string()))?; - let total_progression = body + let client_total_progression = body .get("locator") .and_then(|l| l.get("locations")) .and_then(|l| l.get("totalProgression")) .and_then(|v| v.as_f64()) .unwrap_or(0.0); - let current_page = if book.page_count > 0 { - (total_progression * book.page_count as f64) - .round() - .max(1.0) as i32 + let client_href = body + .get("locator") + .and_then(|l| l.get("href")) + .and_then(|v| v.as_str()) + .unwrap_or(""); + + // 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) + { + if let Some((normalized, position)) = crate::parsers::normalize_progression( + &positions, + client_href, + client_total_progression, + ) { + (normalized, position) + } else { + let page = if book.page_count > 0 { + (client_total_progression * book.page_count as f64) + .round() + .max(1.0) as i32 + } else { + 1 + }; + (client_total_progression, page) + } + } else { + let page = if book.page_count > 0 { + (client_total_progression * book.page_count as f64) + .round() + .max(1.0) as i32 + } else { + 1 + }; + (client_total_progression, page) + } } else { - 1 + let page = if book.page_count > 0 { + (client_total_progression * book.page_count as f64) + .round() + .max(1.0) as i32 + } else { + 1 + }; + (client_total_progression, page) }; + let completed = total_progression >= 0.98 || (book.page_count > 0 && current_page >= book.page_count); + // Store with server-normalized totalProgression + let mut body = body; + if let Some(locator) = body.get_mut("locator") + && let Some(locations) = locator.get_mut("locations") + { + locations["totalProgression"] = serde_json::json!(total_progression); + locations["position"] = serde_json::json!(current_page); + } + let json_str = serde_json::to_string(&body) .map_err(|e| ApiError::Internal(format!("Failed to serialize R2Progression: {}", e)))?; diff --git a/src/db/entities/books.rs b/src/db/entities/books.rs index 9c6c7991..d30fab3f 100644 --- a/src/db/entities/books.rs +++ b/src/db/entities/books.rs @@ -31,6 +31,8 @@ pub struct Model { pub thumbnail_generated_at: Option>, /// KOReader partial MD5 hash for KOReader sync progress tracking pub koreader_hash: Option, + /// EPUB Readium positions list as JSON (for cross-app progression sync) + pub epub_positions: Option, } #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] diff --git a/src/db/repositories/book.rs b/src/db/repositories/book.rs index f2bfe321..347e06de 100644 --- a/src/db/repositories/book.rs +++ b/src/db/repositories/book.rs @@ -399,6 +399,7 @@ impl BookRepository { 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()), + epub_positions: Set(book_model.epub_positions.clone()), }; let created_book = book.insert(db).await.context("Failed to create book")?; @@ -1484,6 +1485,7 @@ impl BookRepository { 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()), + epub_positions: Set(book_model.epub_positions.clone()), }; active.update(db).await.context("Failed to update book")?; @@ -2201,6 +2203,7 @@ impl BookRepository { 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()), + epub_positions: Set(book_model.epub_positions.clone()), }) .collect(); @@ -2263,6 +2266,7 @@ impl BookRepository { 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()), + epub_positions: Set(book_model.epub_positions.clone()), }; active @@ -2491,6 +2495,7 @@ mod tests { thumbnail_path: None, thumbnail_generated_at: None, koreader_hash: None, + epub_positions: None, } } diff --git a/src/db/repositories/book_covers.rs b/src/db/repositories/book_covers.rs index 8d854c04..2dbd4103 100644 --- a/src/db/repositories/book_covers.rs +++ b/src/db/repositories/book_covers.rs @@ -404,6 +404,7 @@ mod tests { thumbnail_path: None, thumbnail_generated_at: None, koreader_hash: None, + epub_positions: 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 dca85317..d0b5652e 100644 --- a/src/db/repositories/book_external_id.rs +++ b/src/db/repositories/book_external_id.rs @@ -371,6 +371,7 @@ mod tests { thumbnail_path: None, thumbnail_generated_at: None, koreader_hash: None, + epub_positions: 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 d7510f28..97c84eed 100644 --- a/src/db/repositories/book_external_links.rs +++ b/src/db/repositories/book_external_links.rs @@ -228,6 +228,7 @@ mod tests { thumbnail_path: None, thumbnail_generated_at: None, koreader_hash: None, + epub_positions: None, }; BookRepository::create(db, &book_model, None).await.unwrap() @@ -602,6 +603,7 @@ mod tests { thumbnail_path: None, thumbnail_generated_at: None, koreader_hash: None, + epub_positions: None, }; let book2_model = books::Model { diff --git a/src/db/repositories/genre.rs b/src/db/repositories/genre.rs index 838398f2..28396b8d 100644 --- a/src/db/repositories/genre.rs +++ b/src/db/repositories/genre.rs @@ -1007,6 +1007,7 @@ mod tests { thumbnail_path: None, thumbnail_generated_at: None, koreader_hash: None, + epub_positions: None, }; BookRepository::create(db.sea_orm_connection(), &book, None) diff --git a/src/db/repositories/metadata.rs b/src/db/repositories/metadata.rs index 0a975b22..d634a00f 100644 --- a/src/db/repositories/metadata.rs +++ b/src/db/repositories/metadata.rs @@ -389,6 +389,7 @@ mod tests { thumbnail_path: None, thumbnail_generated_at: None, koreader_hash: None, + epub_positions: None, }; BookRepository::create(db.sea_orm_connection(), &book, None) diff --git a/src/db/repositories/metrics.rs b/src/db/repositories/metrics.rs index 3730d5c7..8164d409 100644 --- a/src/db/repositories/metrics.rs +++ b/src/db/repositories/metrics.rs @@ -371,6 +371,7 @@ mod tests { thumbnail_path: None, thumbnail_generated_at: None, koreader_hash: None, + epub_positions: None, }; BookRepository::create(db.sea_orm_connection(), &book_model, None) @@ -440,6 +441,7 @@ mod tests { thumbnail_path: None, thumbnail_generated_at: None, koreader_hash: None, + epub_positions: None, }; BookRepository::create(db.sea_orm_connection(), &book_model, None) @@ -512,6 +514,7 @@ mod tests { thumbnail_path: None, thumbnail_generated_at: None, koreader_hash: None, + epub_positions: 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 ba6aade9..6b28aa74 100644 --- a/src/db/repositories/page.rs +++ b/src/db/repositories/page.rs @@ -183,6 +183,7 @@ mod tests { thumbnail_path: None, thumbnail_generated_at: None, koreader_hash: None, + epub_positions: None, }; BookRepository::create(db.sea_orm_connection(), &book, None) .await @@ -238,6 +239,7 @@ mod tests { thumbnail_path: None, thumbnail_generated_at: None, koreader_hash: None, + epub_positions: None, }; BookRepository::create(db.sea_orm_connection(), &book, None) .await @@ -296,6 +298,7 @@ mod tests { thumbnail_path: None, thumbnail_generated_at: None, koreader_hash: None, + epub_positions: None, }; BookRepository::create(db.sea_orm_connection(), &book, None) .await @@ -354,6 +357,7 @@ mod tests { thumbnail_path: None, thumbnail_generated_at: None, koreader_hash: None, + epub_positions: None, }; BookRepository::create(db.sea_orm_connection(), &book, None) .await @@ -414,6 +418,7 @@ mod tests { thumbnail_path: None, thumbnail_generated_at: None, koreader_hash: None, + epub_positions: None, }; BookRepository::create(db.sea_orm_connection(), &book, None) .await @@ -473,6 +478,7 @@ mod tests { thumbnail_path: None, thumbnail_generated_at: None, koreader_hash: None, + epub_positions: None, }; BookRepository::create(db.sea_orm_connection(), &book, None) .await @@ -537,6 +543,7 @@ mod tests { thumbnail_path: None, thumbnail_generated_at: None, koreader_hash: None, + epub_positions: 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 9deec32d..6b42ab5c 100644 --- a/src/db/repositories/read_progress.rs +++ b/src/db/repositories/read_progress.rs @@ -358,6 +358,7 @@ mod tests { thumbnail_path: None, thumbnail_generated_at: None, koreader_hash: None, + epub_positions: None, }; BookRepository::create(db, &book, None).await.unwrap() } diff --git a/src/db/repositories/series.rs b/src/db/repositories/series.rs index 86d49eb6..f414e256 100644 --- a/src/db/repositories/series.rs +++ b/src/db/repositories/series.rs @@ -2451,6 +2451,7 @@ mod tests { thumbnail_path: None, thumbnail_generated_at: None, koreader_hash: None, + epub_positions: None, }; BookRepository::create(db.sea_orm_connection(), &book, None) .await @@ -2546,6 +2547,7 @@ mod tests { thumbnail_path: None, thumbnail_generated_at: None, koreader_hash: None, + epub_positions: None, }; let book1: books::Model = BookRepository::create(db.sea_orm_connection(), &book1, None) .await @@ -2572,6 +2574,7 @@ mod tests { thumbnail_path: None, thumbnail_generated_at: None, koreader_hash: None, + epub_positions: None, }; let book2: books::Model = BookRepository::create(db.sea_orm_connection(), &book2, None) .await @@ -2598,6 +2601,7 @@ mod tests { thumbnail_path: None, thumbnail_generated_at: None, koreader_hash: None, + epub_positions: 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 43f346b1..e4486b3d 100644 --- a/src/db/repositories/tag.rs +++ b/src/db/repositories/tag.rs @@ -996,6 +996,7 @@ mod tests { thumbnail_path: None, thumbnail_generated_at: None, koreader_hash: None, + epub_positions: None, }; BookRepository::create(db.sea_orm_connection(), &book, None) diff --git a/src/parsers/cbr/parser.rs b/src/parsers/cbr/parser.rs index 82624bb6..066bec28 100644 --- a/src/parsers/cbr/parser.rs +++ b/src/parsers/cbr/parser.rs @@ -167,6 +167,7 @@ impl FormatParser for CbrParser { // RELATED: EPUB and PDF parsers successfully extract ISBNs from metadata // (see epub/parser.rs and pdf/parser.rs for implemented approaches) isbns: Vec::new(), + epub_positions: None, }) } } diff --git a/src/parsers/cbz/parser.rs b/src/parsers/cbz/parser.rs index b7806649..5adb9811 100644 --- a/src/parsers/cbz/parser.rs +++ b/src/parsers/cbz/parser.rs @@ -142,6 +142,7 @@ impl FormatParser for CbzParser { // RELATED: EPUB and PDF parsers successfully extract ISBNs from metadata // (see epub/parser.rs and pdf/parser.rs for implemented approaches) isbns: Vec::new(), + epub_positions: None, }) } } diff --git a/src/parsers/epub/parser.rs b/src/parsers/epub/parser.rs index 55d3afe2..d2055c32 100644 --- a/src/parsers/epub/parser.rs +++ b/src/parsers/epub/parser.rs @@ -1,5 +1,6 @@ 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}; @@ -156,10 +157,13 @@ impl EpubParser { } /// Parse the OPF file to get metadata and spine (reading order) + /// + /// Returns (manifest: id -> (href, media_type), spine_order: Vec<(href, media_type)>) + #[allow(clippy::type_complexity)] fn parse_opf( archive: &mut ZipArchive, opf_path: &str, - ) -> Result<(HashMap, Vec)> { + ) -> Result<(HashMap, Vec<(String, String)>)> { let mut opf_file = archive .by_name(opf_path) .map_err(|_| CodexError::ParseError(format!("OPF file not found: {}", opf_path)))?; @@ -174,8 +178,8 @@ impl EpubParser { "" }; - // Parse manifest to get id -> href mapping - let mut manifest: HashMap = HashMap::new(); + // Parse manifest to get id -> (href, media_type) mapping + let mut manifest: HashMap = HashMap::new(); // Simple XML parsing for manifest items (handles both and ) let mut remaining = &xml_content[..]; @@ -203,10 +207,21 @@ impl EpubParser { None }; + // Extract media-type + let media_type = if let Some(mt_start) = item_tag.find("media-type=\"") { + let mt_value_start = mt_start + 12; + item_tag[mt_value_start..] + .find('"') + .map(|mt_end| &item_tag[mt_value_start..mt_value_start + mt_end]) + } else { + None + }; + if let (Some(id), Some(href)) = (id, href) { // Combine base path with href let full_path = format!("{}{}", base_path, href); - manifest.insert(id.to_string(), full_path); + let mt = media_type.unwrap_or("application/octet-stream").to_string(); + manifest.insert(id.to_string(), (full_path, mt)); } remaining = &item_section[item_end..]; @@ -217,7 +232,7 @@ impl EpubParser { // Parse spine to get reading order (idref list) // Handles both and , and - let mut spine_order: Vec = Vec::new(); + let mut spine_order: Vec<(String, String)> = Vec::new(); remaining = &xml_content[..]; if let Some((_pos, spine_section)) = find_xml_tag(remaining, "spine") @@ -237,8 +252,8 @@ impl EpubParser { if let Some(idref_end) = itemref_tag[idref_value_start..].find('"') { let idref = &itemref_tag[idref_value_start..idref_value_start + idref_end]; - if let Some(path) = manifest.get(idref) { - spine_order.push(path.clone()); + if let Some((path, mt)) = manifest.get(idref) { + spine_order.push((path.clone(), mt.clone())); } } } @@ -312,6 +327,21 @@ impl FormatParser for EpubParser { // Parse the OPF to get manifest and spine let (_manifest, spine_order) = Self::parse_opf(&mut archive, &opf_path)?; + // Build spine items with file sizes for Readium positions computation + let spine_items: Vec = spine_order + .iter() + .filter_map(|(href, media_type)| { + archive.by_name(href).ok().map(|entry| SpineItem { + href: href.clone(), + media_type: media_type.clone(), + file_size: entry.size(), + }) + }) + .collect(); + + // Compute Readium positions (1 position per 1024 bytes) + let epub_positions = compute_epub_positions(&spine_items); + // Collect and sort image files let mut image_entries: Vec<(usize, String)> = Vec::new(); for i in 0..archive.len() { @@ -371,15 +401,14 @@ impl FormatParser for EpubParser { } // Page count logic for EPUB: - // EPUBs are primarily text-based documents with a spine (reading order) and optional images. - // We use the maximum of: - // - spine_order.len(): Number of content items (chapters/sections) in reading order - // - pages.len(): Number of extracted images (covers, illustrations) - // - // This gives a reasonable page count estimate, though EPUBs don't have fixed "pages" - // like comics do. For pure image-based EPUBs (like converted manga), pages.len() - // will be higher. For text-heavy novels, spine_order.len() will be higher. - let page_count = spine_order.len().max(pages.len()); + // Use the Readium positions count if available (the standard way to count EPUB "pages"). + // This matches Komga's approach and provides consistent page counts across apps. + // Fall back to max(spine items, image count) for edge cases. + let page_count = if !epub_positions.is_empty() { + epub_positions.len() + } else { + spine_order.len().max(pages.len()) + }; Ok(BookMetadata { file_path: path.to_string_lossy().to_string(), @@ -391,6 +420,11 @@ impl FormatParser for EpubParser { pages, comic_info, isbns, + epub_positions: if epub_positions.is_empty() { + None + } else { + Some(epub_positions) + }, }) } } diff --git a/src/parsers/metadata.rs b/src/parsers/metadata.rs index 44de9122..be812d13 100644 --- a/src/parsers/metadata.rs +++ b/src/parsers/metadata.rs @@ -238,6 +238,119 @@ pub struct ComicInfo { pub manga: Option, } +/// A single position in the Readium positions list for EPUB books. +/// +/// Positions are computed using the Readium algorithm (1 position per 1024 bytes +/// of each spine resource). This provides a canonical coordinate system for +/// cross-app reading position sync, matching Komga's implementation. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct EpubPosition { + /// Resource href within the EPUB (e.g., "OEBPS/chapter1.xhtml") + pub href: String, + /// Media type of the resource (e.g., "application/xhtml+xml") + pub media_type: String, + /// Progression within the resource (0.0-1.0) + pub progression: f64, + /// Sequential position number (1-based) across the entire book + pub position: i32, + /// Overall progression within the entire book (0.0-1.0) + pub total_progression: f64, +} + +/// Spine item extracted from the EPUB OPF manifest +#[derive(Debug, Clone)] +pub struct SpineItem { + /// Full path within the EPUB archive + pub href: String, + /// Media type from the manifest + pub media_type: String, + /// Uncompressed file size in bytes + pub file_size: u64, +} + +/// Compute Readium positions list from spine items. +/// +/// Uses the Readium algorithm: 1 position per 1024 bytes of each spine resource. +/// This matches Komga's implementation for cross-app compatibility. +pub fn compute_epub_positions(spine_items: &[SpineItem]) -> Vec { + let mut positions = Vec::new(); + let mut next_position: i32 = 1; + + for item in spine_items { + let position_count = (item.file_size as f64 / 1024.0).ceil().max(1.0) as usize; + + for p in 0..position_count { + let progression = p as f64 / position_count as f64; + positions.push(EpubPosition { + href: item.href.clone(), + media_type: item.media_type.clone(), + progression, + position: next_position, + total_progression: 0.0, // computed below + }); + next_position += 1; + } + } + + // Compute total_progression for each position + let total = positions.len() as f64; + for pos in &mut positions { + pos.total_progression = pos.position as f64 / total; + } + + positions +} + +/// Normalize a client's totalProgression using the server's positions list. +/// +/// Given the client's `href` and `total_progression`, finds the closest matching +/// position in the server's positions list and returns its authoritative +/// `total_progression` value along with the derived page number. +/// +/// Returns `None` if positions is empty or href doesn't match any position. +pub fn normalize_progression( + positions: &[EpubPosition], + client_href: &str, + client_total_progression: f64, +) -> Option<(f64, i32)> { + if positions.is_empty() { + return None; + } + + // Strip fragment from href and URL-decode + let href_clean = client_href.split('#').next().unwrap_or(client_href); + let href_decoded = urlencoding::decode(href_clean).unwrap_or_else(|_| href_clean.into()); + + // Find positions matching the href (try exact match, then suffix match) + let matching: Vec<&EpubPosition> = positions + .iter() + .filter(|p| { + p.href == href_decoded.as_ref() + || href_decoded.ends_with(&p.href) + || p.href.ends_with(href_decoded.as_ref()) + }) + .collect(); + + if matching.is_empty() { + // No href match; fall back to closest totalProgression across all positions + let closest = positions.iter().min_by(|a, b| { + let da = (a.total_progression - client_total_progression).abs(); + let db = (b.total_progression - client_total_progression).abs(); + da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal) + })?; + return Some((closest.total_progression, closest.position)); + } + + // Among matching positions, find the one closest to client's totalProgression + let closest = matching.iter().min_by(|a, b| { + let da = (a.total_progression - client_total_progression).abs(); + let db = (b.total_progression - client_total_progression).abs(); + da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal) + })?; + + Some((closest.total_progression, closest.position)) +} + /// Complete book metadata #[derive(Debug, Clone, Serialize, Deserialize)] pub struct BookMetadata { @@ -259,6 +372,9 @@ pub struct BookMetadata { pub comic_info: Option, /// Detected ISBNs/barcodes pub isbns: Vec, + /// EPUB Readium positions list (only for EPUB format) + #[serde(skip_serializing_if = "Option::is_none")] + pub epub_positions: Option>, } #[cfg(test)] @@ -564,4 +680,103 @@ mod tests { ); } } + + mod epub_positions { + use super::*; + + fn sample_spine() -> Vec { + vec![ + SpineItem { + href: "OEBPS/chapter1.xhtml".to_string(), + media_type: "application/xhtml+xml".to_string(), + file_size: 2048, // 2 positions + }, + SpineItem { + href: "OEBPS/chapter2.xhtml".to_string(), + media_type: "application/xhtml+xml".to_string(), + file_size: 3072, // 3 positions + }, + ] + } + + #[test] + fn test_compute_positions_count() { + let positions = compute_epub_positions(&sample_spine()); + assert_eq!(positions.len(), 5); // 2 + 3 + } + + #[test] + fn test_compute_positions_sequential() { + let positions = compute_epub_positions(&sample_spine()); + for (i, pos) in positions.iter().enumerate() { + assert_eq!(pos.position, (i + 1) as i32); + } + } + + #[test] + fn test_compute_positions_total_progression() { + let positions = compute_epub_positions(&sample_spine()); + assert!((positions[0].total_progression - 1.0 / 5.0).abs() < 1e-10); + assert!((positions[4].total_progression - 5.0 / 5.0).abs() < 1e-10); + } + + #[test] + fn test_compute_positions_min_one_per_resource() { + let spine = vec![SpineItem { + href: "tiny.xhtml".to_string(), + media_type: "application/xhtml+xml".to_string(), + file_size: 100, + }]; + let positions = compute_epub_positions(&spine); + assert_eq!(positions.len(), 1); + } + + #[test] + fn test_normalize_exact_match() { + let positions = compute_epub_positions(&sample_spine()); + let (tp, pos) = normalize_progression(&positions, "OEBPS/chapter1.xhtml", 0.2).unwrap(); + assert_eq!(pos, 1); + assert!((tp - 1.0 / 5.0).abs() < 1e-10); + } + + #[test] + fn test_normalize_suffix_match() { + let positions = compute_epub_positions(&sample_spine()); + let result = normalize_progression(&positions, "chapter2.xhtml", 0.7); + assert!(result.is_some()); + let (_, pos) = result.unwrap(); + assert!((3..=5).contains(&pos)); + } + + #[test] + fn test_normalize_with_fragment() { + let positions = compute_epub_positions(&sample_spine()); + let result = normalize_progression(&positions, "OEBPS/chapter1.xhtml#section1", 0.2); + assert!(result.is_some()); + } + + #[test] + fn test_normalize_url_encoded() { + let spine = vec![SpineItem { + href: "OEBPS/chapter 1.xhtml".to_string(), + media_type: "application/xhtml+xml".to_string(), + file_size: 1024, + }]; + let positions = compute_epub_positions(&spine); + let result = normalize_progression(&positions, "OEBPS/chapter%201.xhtml", 0.5); + assert!(result.is_some()); + } + + #[test] + fn test_normalize_empty_positions() { + assert!(normalize_progression(&[], "test.xhtml", 0.5).is_none()); + } + + #[test] + fn test_normalize_no_href_match_falls_back() { + let positions = compute_epub_positions(&sample_spine()); + let result = normalize_progression(&positions, "nonexistent.xhtml", 0.6); + assert!(result.is_some()); + } + } } diff --git a/src/parsers/pdf/parser.rs b/src/parsers/pdf/parser.rs index c3f27305..981b15b9 100644 --- a/src/parsers/pdf/parser.rs +++ b/src/parsers/pdf/parser.rs @@ -311,6 +311,7 @@ impl FormatParser for PdfParser { pages, comic_info: None, // PDF doesn't use ComicInfo.xml isbns, + epub_positions: None, }) } } diff --git a/src/scanner/analyzer_queue.rs b/src/scanner/analyzer_queue.rs index 776fab3e..7354d7ff 100644 --- a/src/scanner/analyzer_queue.rs +++ b/src/scanner/analyzer_queue.rs @@ -297,6 +297,10 @@ async fn analyze_single_book( book.analyzed = true; // Mark as analyzed book.analysis_error = None; // Clear any previous error on successful analysis book.updated_at = now; + book.epub_positions = metadata + .epub_positions + .as_ref() + .map(|positions| serde_json::to_string(positions).unwrap_or_default()); BookRepository::update(db, &book, event_broadcaster).await?; diff --git a/src/scanner/library_scanner.rs b/src/scanner/library_scanner.rs index 48d1f8be..3759f8b7 100644 --- a/src/scanner/library_scanner.rs +++ b/src/scanner/library_scanner.rs @@ -1032,6 +1032,7 @@ async fn process_series_batched( thumbnail_path: None, thumbnail_generated_at: None, koreader_hash: file_hash.koreader_hash, + epub_positions: None, }; batch.add_create(book_model, true); diff --git a/src/services/read_progress.rs b/src/services/read_progress.rs index bff9eda8..c7b871bf 100644 --- a/src/services/read_progress.rs +++ b/src/services/read_progress.rs @@ -275,6 +275,7 @@ mod tests { thumbnail_path: None, thumbnail_generated_at: None, koreader_hash: None, + epub_positions: 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 eb383eba..dbbfa01f 100644 --- a/src/tasks/handlers/user_plugin_sync/tests.rs +++ b/src/tasks/handlers/user_plugin_sync/tests.rs @@ -56,6 +56,7 @@ async fn create_test_book( thumbnail_path: None, thumbnail_generated_at: None, koreader_hash: None, + epub_positions: None, }; BookRepository::create(db, &book, None).await.unwrap() } diff --git a/tests/api/books.rs b/tests/api/books.rs index 327bced0..3e441cc0 100644 --- a/tests/api/books.rs +++ b/tests/api/books.rs @@ -60,6 +60,7 @@ fn create_test_book_model( thumbnail_path: None, thumbnail_generated_at: None, koreader_hash: None, + epub_positions: None, } } diff --git a/tests/api/bulk_metadata.rs b/tests/api/bulk_metadata.rs index bb1eaede..bb344273 100644 --- a/tests/api/bulk_metadata.rs +++ b/tests/api/bulk_metadata.rs @@ -61,6 +61,7 @@ fn create_test_book_model( thumbnail_path: None, thumbnail_generated_at: None, koreader_hash: None, + epub_positions: None, } } diff --git a/tests/api/bulk_operations.rs b/tests/api/bulk_operations.rs index c8f79341..92e28876 100644 --- a/tests/api/bulk_operations.rs +++ b/tests/api/bulk_operations.rs @@ -62,6 +62,7 @@ fn create_test_book_model( thumbnail_path: None, thumbnail_generated_at: None, koreader_hash: None, + epub_positions: None, } } diff --git a/tests/api/covers.rs b/tests/api/covers.rs index fc3c093f..392d708b 100644 --- a/tests/api/covers.rs +++ b/tests/api/covers.rs @@ -695,6 +695,7 @@ fn create_test_book_model( thumbnail_path: None, thumbnail_generated_at: None, koreader_hash: None, + epub_positions: None, } } diff --git a/tests/api/genres.rs b/tests/api/genres.rs index 3c55569d..9c3ab3c3 100644 --- a/tests/api/genres.rs +++ b/tests/api/genres.rs @@ -601,6 +601,7 @@ fn create_test_book_model( thumbnail_path: None, thumbnail_generated_at: None, koreader_hash: None, + epub_positions: None, } } diff --git a/tests/api/metadata_locks.rs b/tests/api/metadata_locks.rs index de3d8a10..7bde0032 100644 --- a/tests/api/metadata_locks.rs +++ b/tests/api/metadata_locks.rs @@ -1020,6 +1020,7 @@ fn create_test_book_model( thumbnail_path: None, thumbnail_generated_at: None, koreader_hash: None, + epub_positions: None, } } diff --git a/tests/api/opds.rs b/tests/api/opds.rs index 76864f52..87287e0a 100644 --- a/tests/api/opds.rs +++ b/tests/api/opds.rs @@ -535,6 +535,7 @@ fn create_test_book_model( thumbnail_path: None, thumbnail_generated_at: None, koreader_hash: None, + epub_positions: None, } } diff --git a/tests/api/opds2.rs b/tests/api/opds2.rs index 11e91b94..ca863872 100644 --- a/tests/api/opds2.rs +++ b/tests/api/opds2.rs @@ -916,6 +916,7 @@ fn create_test_book_model( thumbnail_path: None, thumbnail_generated_at: None, koreader_hash: None, + epub_positions: None, } } diff --git a/tests/api/pages.rs b/tests/api/pages.rs index 1f9a5dff..d4eaea18 100644 --- a/tests/api/pages.rs +++ b/tests/api/pages.rs @@ -63,6 +63,7 @@ fn create_test_book_model( thumbnail_path: None, thumbnail_generated_at: None, koreader_hash: None, + epub_positions: None, } } diff --git a/tests/api/read_progress.rs b/tests/api/read_progress.rs index 9da61721..4235a3f8 100644 --- a/tests/api/read_progress.rs +++ b/tests/api/read_progress.rs @@ -58,6 +58,7 @@ fn create_test_book_model( thumbnail_path: None, thumbnail_generated_at: None, koreader_hash: None, + epub_positions: None, } } diff --git a/tests/api/series.rs b/tests/api/series.rs index 1ee5d870..8be6b313 100644 --- a/tests/api/series.rs +++ b/tests/api/series.rs @@ -1326,6 +1326,7 @@ fn create_test_book( thumbnail_path: None, thumbnail_generated_at: None, koreader_hash: None, + epub_positions: None, } } diff --git a/tests/api/tags.rs b/tests/api/tags.rs index 332e08f6..fb2b2f7e 100644 --- a/tests/api/tags.rs +++ b/tests/api/tags.rs @@ -577,6 +577,7 @@ fn create_test_book_model( thumbnail_path: None, thumbnail_generated_at: None, koreader_hash: None, + epub_positions: None, } } diff --git a/tests/common/fixtures.rs b/tests/common/fixtures.rs index 3ed58284..b4f41f9e 100644 --- a/tests/common/fixtures.rs +++ b/tests/common/fixtures.rs @@ -85,6 +85,7 @@ pub fn create_test_book( thumbnail_path: None, thumbnail_generated_at: None, koreader_hash: None, + epub_positions: None, } } @@ -206,6 +207,7 @@ pub async fn create_test_book_with_hash( thumbnail_path: None, thumbnail_generated_at: None, koreader_hash: None, + epub_positions: None, updated_at: Utc::now(), }; diff --git a/tests/db/postgres.rs b/tests/db/postgres.rs index 8e9ff12d..a1e251ab 100644 --- a/tests/db/postgres.rs +++ b/tests/db/postgres.rs @@ -146,6 +146,7 @@ async fn test_postgres_series_book_relationship() { thumbnail_path: None, thumbnail_generated_at: None, koreader_hash: None, + epub_positions: None, }; let book = BookRepository::create(conn, &book_model, None) @@ -293,6 +294,7 @@ async fn test_postgres_metrics_repository() { thumbnail_path: None, thumbnail_generated_at: None, koreader_hash: None, + epub_positions: None, }; BookRepository::create(conn, &book_model, None) diff --git a/tests/scanner/book_analysis_metadata.rs b/tests/scanner/book_analysis_metadata.rs index efd258e1..1d629565 100644 --- a/tests/scanner/book_analysis_metadata.rs +++ b/tests/scanner/book_analysis_metadata.rs @@ -62,6 +62,7 @@ async fn create_test_book( thumbnail_path: None, thumbnail_generated_at: None, koreader_hash: None, + epub_positions: None, }; let created_book = BookRepository::create(db.sea_orm_connection(), &book, None).await?; @@ -107,6 +108,7 @@ async fn create_test_book_with_strategy( thumbnail_path: None, thumbnail_generated_at: None, koreader_hash: None, + epub_positions: None, }; let created_book = BookRepository::create(db.sea_orm_connection(), &book, None).await?; @@ -869,6 +871,7 @@ async fn test_series_metadata_populated_from_first_book() -> Result<()> { thumbnail_path: None, thumbnail_generated_at: None, koreader_hash: None, + epub_positions: None, }; BookRepository::create(db.sea_orm_connection(), &book1, None).await?; @@ -928,6 +931,7 @@ async fn test_series_metadata_populated_from_first_book() -> Result<()> { thumbnail_path: None, thumbnail_generated_at: None, koreader_hash: None, + epub_positions: None, }; BookRepository::create(db.sea_orm_connection(), &book2, None).await?; @@ -1097,6 +1101,7 @@ async fn test_series_title_sort_populated_from_title() -> Result<()> { thumbnail_path: None, thumbnail_generated_at: None, koreader_hash: None, + epub_positions: None, }; BookRepository::create(db.sea_orm_connection(), &book, None).await?; @@ -1194,6 +1199,7 @@ async fn test_series_title_sort_respects_lock() -> Result<()> { thumbnail_path: None, thumbnail_generated_at: None, koreader_hash: None, + epub_positions: None, }; BookRepository::create(db.sea_orm_connection(), &book, None).await?; @@ -1298,6 +1304,7 @@ async fn test_series_title_sort_not_overwritten_if_already_set() -> Result<()> { thumbnail_path: None, thumbnail_generated_at: None, koreader_hash: None, + epub_positions: None, }; BookRepository::create(db.sea_orm_connection(), &book, None).await?; @@ -1376,6 +1383,7 @@ async fn test_series_title_sort_populated_without_comic_info() -> Result<()> { thumbnail_path: None, thumbnail_generated_at: None, koreader_hash: None, + epub_positions: 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 dc89ccea..ebfc5243 100644 --- a/tests/scanner/force_analysis.rs +++ b/tests/scanner/force_analysis.rs @@ -44,6 +44,7 @@ async fn create_analyzed_book( thumbnail_path: None, thumbnail_generated_at: None, koreader_hash: None, + epub_positions: None, }; BookRepository::create(db_conn, &book, None).await?; From 516903335058cf77b50666017e297a98b4f012f1 Mon Sep 17 00:00:00 2001 From: Sylvain Cau Date: Sat, 14 Mar 2026 23:53:06 -0700 Subject: [PATCH 5/9] fix(epub): stop overwriting client R2Progression locator with server-normalized values The server was mutating the stored R2Progression JSON by replacing totalProgression and position in the locator with server-computed values. This broke cross-device sync because each client relies on its own locator (href + progression/CFI) for navigation, and the server-normalized values could differ from what the client originally sent. Now the R2Progression body is stored as-is from the client. The server's normalized values are still used internally for current_page and percentage tracking but no longer overwrite the client's locator data. --- src/api/routes/komga/dto/manifest.rs | 52 ++ src/api/routes/komga/dto/mod.rs | 1 + src/api/routes/komga/handlers/manifest.rs | 604 ++++++++++++++++++ src/api/routes/komga/handlers/mod.rs | 5 + .../routes/komga/handlers/read_progress.rs | 12 +- src/api/routes/komga/routes/books.rs | 8 + src/api/routes/v1/handlers/read_progress.rs | 12 +- src/db/repositories/read_progress.rs | 6 +- src/parsers/epub/parser.rs | 4 +- web/src/components/reader/EpubReader.tsx | 147 +++-- .../components/reader/EpubReaderSettings.tsx | 24 + .../reader/hooks/useEpubProgress.ts | 17 + web/src/store/readerStore.ts | 10 + 13 files changed, 845 insertions(+), 57 deletions(-) create mode 100644 src/api/routes/komga/dto/manifest.rs create mode 100644 src/api/routes/komga/handlers/manifest.rs diff --git a/src/api/routes/komga/dto/manifest.rs b/src/api/routes/komga/dto/manifest.rs new file mode 100644 index 00000000..8ccb603d --- /dev/null +++ b/src/api/routes/komga/dto/manifest.rs @@ -0,0 +1,52 @@ +//! Readium WebPub Manifest DTOs for Komga-compatible EPUB reading +//! +//! These structures represent the Readium WebPub Manifest format that Komga returns +//! for EPUB books, enabling streaming EPUB reading in compatible apps like Komic. + +use serde::Serialize; +use utoipa::ToSchema; + +/// Readium WebPub Manifest +/// +/// Root structure for the manifest returned by the EPUB manifest endpoint. +/// Conforms to the Readium WebPub Manifest specification. +#[derive(Debug, Serialize, ToSchema)] +pub struct WebPubManifest { + #[serde(rename = "@context")] + pub context: String, + pub metadata: WebPubMetadata, + #[serde(rename = "readingOrder")] + pub reading_order: Vec, + pub resources: Vec, + pub toc: Vec, +} + +/// Metadata section of the WebPub Manifest +#[derive(Debug, Serialize, ToSchema)] +pub struct WebPubMetadata { + pub identifier: String, + pub title: String, + #[serde(rename = "@type")] + pub schema_type: String, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub author: Vec, + #[serde(rename = "numberOfPages")] + pub number_of_pages: i32, +} + +/// A link entry in readingOrder or resources +#[derive(Debug, Serialize, ToSchema)] +pub struct WebPubLink { + pub href: String, + #[serde(rename = "type")] + pub media_type: String, +} + +/// A table of contents entry +#[derive(Debug, Serialize, ToSchema)] +pub struct WebPubTocEntry { + pub href: String, + pub title: String, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub children: Vec, +} diff --git a/src/api/routes/komga/dto/mod.rs b/src/api/routes/komga/dto/mod.rs index a9a821fc..49f6cb05 100644 --- a/src/api/routes/komga/dto/mod.rs +++ b/src/api/routes/komga/dto/mod.rs @@ -5,6 +5,7 @@ pub mod book; pub mod library; +pub mod manifest; pub mod page; pub mod pagination; pub mod series; diff --git a/src/api/routes/komga/handlers/manifest.rs b/src/api/routes/komga/handlers/manifest.rs new file mode 100644 index 00000000..0b75c727 --- /dev/null +++ b/src/api/routes/komga/handlers/manifest.rs @@ -0,0 +1,604 @@ +//! Komga-compatible EPUB manifest and resource handlers +//! +//! Provides endpoints for streaming EPUB reading via the Readium WebPub Manifest format. +//! This enables apps like Komic to read EPUBs without downloading the entire file. + +use super::super::dto::manifest::{WebPubLink, WebPubManifest, WebPubMetadata, WebPubTocEntry}; +use crate::api::{ + error::ApiError, + extractors::{AuthState, FlexibleAuthContext}, + permissions::Permission, +}; +use crate::db::repositories::{BookMetadataRepository, BookRepository}; +use crate::parsers::epub::EpubParser; +use crate::require_permission; +use axum::{ + Json, + body::Body, + extract::{OriginalUri, Path, State}, + http::{StatusCode, header}, + response::Response, +}; +use std::collections::HashSet; +use std::io::Read; +use std::sync::Arc; +use uuid::Uuid; +use zip::ZipArchive; + +/// Get EPUB manifest (Readium WebPub Manifest) +/// +/// Returns a Readium WebPub Manifest JSON for an EPUB book, enabling +/// streaming EPUB reading in compatible apps. +/// +/// ## Endpoint +/// `GET /{prefix}/api/v1/books/{bookId}/manifest/epub` +/// +/// ## Authentication +/// - Bearer token (JWT) +/// - Basic Auth +/// - API Key +#[utoipa::path( + get, + path = "/{prefix}/api/v1/books/{book_id}/manifest/epub", + responses( + (status = 200, description = "EPUB WebPub Manifest", body = WebPubManifest), + (status = 400, description = "Book is not EPUB format"), + (status = 401, description = "Unauthorized"), + (status = 404, description = "Book not found"), + ), + params( + ("prefix" = String, Path, description = "Komga API prefix (default: komga)"), + ("book_id" = Uuid, Path, description = "Book ID") + ), + security( + ("jwt_bearer" = []), + ("api_key" = []) + ), + tag = "Komga" +)] +pub async fn get_epub_manifest( + State(state): State>, + FlexibleAuthContext(auth): FlexibleAuthContext, + OriginalUri(uri): OriginalUri, + Path(book_id): Path, +) -> Result, ApiError> { + require_permission!(auth, Permission::BooksRead)?; + + let book = BookRepository::get_by_id(&state.db, book_id) + .await + .map_err(|e| ApiError::Internal(format!("Failed to fetch book: {}", e)))? + .ok_or_else(|| ApiError::NotFound("Book not found".to_string()))?; + + if book.format.to_lowercase() != "epub" { + return Err(ApiError::BadRequest( + "Book is not in EPUB format".to_string(), + )); + } + + // Derive base URL for resource links from the request URI. + // URI is like: /{prefix}/api/v1/books/{id}/manifest/epub + // We need: /{prefix}/api/v1/books/{id}/resource/ + let uri_path = uri.path().to_string(); + let base_url = uri_path + .rfind("/manifest/epub") + .map(|pos| &uri_path[..pos]) + .unwrap_or(&uri_path); + + // Open EPUB as ZIP + let file_path = book.file_path.clone(); + let (manifest_items, spine_order, toc_entries, metadata) = + tokio::task::spawn_blocking(move || -> Result<_, ApiError> { + let file = std::fs::File::open(&file_path) + .map_err(|e| ApiError::Internal(format!("Failed to open EPUB file: {}", e)))?; + let mut archive = ZipArchive::new(file) + .map_err(|e| ApiError::Internal(format!("Failed to read EPUB archive: {}", e)))?; + + let opf_path = EpubParser::find_root_file(&mut archive) + .map_err(|e| ApiError::Internal(format!("Failed to find OPF: {}", e)))?; + + let (manifest, spine) = EpubParser::parse_opf(&mut archive, &opf_path) + .map_err(|e| ApiError::Internal(format!("Failed to parse OPF: {}", e)))?; + + // Parse TOC from NCX file + let toc = parse_toc(&mut archive, &manifest, &opf_path); + + Ok((manifest, spine, toc, opf_path)) + }) + .await + .map_err(|e| ApiError::Internal(format!("Task join error: {}", e)))??; + + let _ = metadata; // opf_path not needed further + + // Get book metadata for title/author + let book_metadata = BookMetadataRepository::get_by_book_id(&state.db, book_id) + .await + .ok() + .flatten(); + + let title = book_metadata + .as_ref() + .and_then(|m| m.title.clone()) + .unwrap_or_else(|| book.file_name.clone()); + + let authors: Vec = book_metadata + .as_ref() + .and_then(|m| m.authors_json.as_ref()) + .and_then(|json| { + // authors_json is stored as JSON array of objects with "name" and "role" + serde_json::from_str::>(json) + .ok() + .map(|arr| { + arr.iter() + .filter_map(|v| v.get("name").and_then(|n| n.as_str()).map(String::from)) + .collect() + }) + }) + .unwrap_or_default(); + + // Build spine href set for separating reading_order from resources + let spine_hrefs: HashSet<&str> = spine_order.iter().map(|(href, _)| href.as_str()).collect(); + + // Build readingOrder + let reading_order: Vec = spine_order + .iter() + .map(|(href, media_type)| WebPubLink { + href: format!("{}/resource/{}", base_url, encode_resource_path(href)), + media_type: media_type.clone(), + }) + .collect(); + + // Build resources (manifest items not in spine) + let resources: Vec = manifest_items + .values() + .filter(|(href, _)| !spine_hrefs.contains(href.as_str())) + .map(|(href, media_type)| WebPubLink { + href: format!("{}/resource/{}", base_url, encode_resource_path(href)), + media_type: media_type.clone(), + }) + .collect(); + + // Build TOC with rewritten hrefs + let toc: Vec = toc_entries + .into_iter() + .map(|entry| rewrite_toc_hrefs(entry, base_url)) + .collect(); + + let manifest = WebPubManifest { + context: "https://readium.org/webpub-manifest/context.jsonld".to_string(), + metadata: WebPubMetadata { + identifier: format!("urn:uuid:{}", book_id), + title, + schema_type: "http://schema.org/Book".to_string(), + author: authors, + number_of_pages: book.page_count, + }, + reading_order, + resources, + toc, + }; + + Ok(Json(manifest)) +} + +/// Get a resource file from within an EPUB +/// +/// Serves individual files (XHTML chapters, CSS, images, fonts) from within +/// an EPUB archive. Used by EPUB readers to load content referenced in the manifest. +/// +/// ## Endpoint +/// `GET /{prefix}/api/v1/books/{bookId}/resource/*resource` +/// +/// ## Authentication +/// - Bearer token (JWT) +/// - Basic Auth +/// - API Key +#[utoipa::path( + get, + path = "/{prefix}/api/v1/books/{book_id}/resource/{resource}", + responses( + (status = 200, description = "Resource file content"), + (status = 400, description = "Invalid resource path"), + (status = 401, description = "Unauthorized"), + (status = 404, description = "Book or resource not found"), + ), + params( + ("prefix" = String, Path, description = "Komga API prefix (default: komga)"), + ("book_id" = Uuid, Path, description = "Book ID"), + ("resource" = String, Path, description = "Resource path within the EPUB") + ), + security( + ("jwt_bearer" = []), + ("api_key" = []) + ), + tag = "Komga" +)] +pub async fn get_epub_resource( + State(state): State>, + FlexibleAuthContext(auth): FlexibleAuthContext, + Path((book_id, resource)): Path<(Uuid, String)>, +) -> Result { + require_permission!(auth, Permission::BooksRead)?; + + // Decode percent-encoded path; strip leading '/' from wildcard capture + let resource = resource.strip_prefix('/').unwrap_or(&resource); + let resource = percent_decode(resource); + + // Security: reject path traversal attempts + if resource.contains("..") || resource.starts_with('/') { + return Err(ApiError::BadRequest("Invalid resource path".to_string())); + } + + let book = BookRepository::get_by_id(&state.db, book_id) + .await + .map_err(|e| ApiError::Internal(format!("Failed to fetch book: {}", e)))? + .ok_or_else(|| ApiError::NotFound("Book not found".to_string()))?; + + if book.format.to_lowercase() != "epub" { + return Err(ApiError::BadRequest( + "Book is not in EPUB format".to_string(), + )); + } + + let file_path = book.file_path.clone(); + let resource_path = resource.clone(); + + let (data, content_type) = + tokio::task::spawn_blocking(move || -> Result<(Vec, String), ApiError> { + let file = std::fs::File::open(&file_path) + .map_err(|e| ApiError::Internal(format!("Failed to open EPUB file: {}", e)))?; + let mut archive = ZipArchive::new(file) + .map_err(|e| ApiError::Internal(format!("Failed to read EPUB archive: {}", e)))?; + + let mut entry = archive.by_name(&resource_path).map_err(|_| { + ApiError::NotFound(format!("Resource not found in EPUB: {}", resource_path)) + })?; + + let mut buf = Vec::with_capacity(entry.size() as usize); + entry + .read_to_end(&mut buf) + .map_err(|e| ApiError::Internal(format!("Failed to read resource: {}", e)))?; + + // Determine content type from file extension + let ct = mime_guess::from_path(&resource_path) + .first_or_octet_stream() + .to_string(); + + Ok((buf, ct)) + }) + .await + .map_err(|e| ApiError::Internal(format!("Task join error: {}", e)))??; + + Ok(Response::builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, content_type) + .header(header::CONTENT_LENGTH, data.len()) + .header(header::CACHE_CONTROL, "public, max-age=86400") + .body(Body::from(data)) + .unwrap()) +} + +// ============================================================================ +// Helper Functions +// ============================================================================ + +/// Parse TOC from NCX (EPUB 2) or nav document (EPUB 3) +fn parse_toc( + archive: &mut ZipArchive, + manifest: &std::collections::HashMap, + _opf_path: &str, +) -> Vec { + // Try NCX first (EPUB 2) - look for application/x-dtbncx+xml in manifest + if let Some((ncx_href, _)) = manifest + .values() + .find(|(_, mt)| mt == "application/x-dtbncx+xml") + && let Ok(entries) = parse_ncx(archive, ncx_href) + && !entries.is_empty() + { + return entries; + } + + // Try EPUB 3 nav document: check all xhtml files for epub:type="toc" + for (nav_href, _) in manifest + .values() + .filter(|(_, mt)| mt == "application/xhtml+xml") + { + if let Ok(entries) = parse_nav_doc(archive, nav_href) + && !entries.is_empty() + { + return entries; + } + } + + Vec::new() +} + +/// Parse NCX file for table of contents +fn parse_ncx( + archive: &mut ZipArchive, + ncx_href: &str, +) -> Result, ()> { + let mut ncx_file = archive.by_name(ncx_href).map_err(|_| ())?; + let mut content = String::new(); + ncx_file.read_to_string(&mut content).map_err(|_| ())?; + + // Determine base path from NCX href for resolving relative paths + let base_path = ncx_href + .rfind('/') + .map(|pos| &ncx_href[..pos + 1]) + .unwrap_or(""); + + Ok(parse_nav_points(&content, base_path)) +} + +/// Recursively parse navPoint elements from NCX content +fn parse_nav_points(content: &str, base_path: &str) -> Vec { + let mut entries = Vec::new(); + let mut remaining = content; + + while let Some(np_start) = remaining.find("') else { + break; + }; + let inner = §ion[inner_start + 1..]; + + // Extract navLabel > text + let title = extract_between(inner, "", "") + .or_else(|| extract_between(inner, "", "")) + .unwrap_or_default(); + + // Extract content src + let href = inner + .find(" to delimit this entry + let children_content = find_nav_point_children(inner); + let children = if !children_content.is_empty() { + parse_nav_points(children_content, base_path) + } else { + Vec::new() + }; + + if !title.is_empty() { + entries.push(WebPubTocEntry { + href: full_href, + title, + children, + }); + } + + // Move past this navPoint's opening tag to find the next sibling + // We need to skip past nested navPoints, so find the closing + if let Some(close_pos) = find_closing_nav_point(section) { + remaining = §ion[close_pos..]; + } else { + break; + } + } + + entries +} + +/// Find the content between the first nested navPoint and the closing +fn find_nav_point_children(content: &str) -> &str { + // Check if there are nested navPoints + if let Some(first_child) = content.find("") + && first_child < close + { + return &content[first_child..close]; + } + "" +} + +/// Find the position after the matching closing tag +fn find_closing_nav_point(content: &str) -> Option { + let mut depth = 0; + let mut pos = 0; + + while pos < content.len() { + if content[pos..].starts_with("") { + depth -= 1; + if depth == 0 { + return Some(pos + 11); // skip "" + } + pos += 11; + } else { + pos += 1; + } + } + None +} + +/// Parse EPUB 3 nav document for table of contents +fn parse_nav_doc( + archive: &mut ZipArchive, + nav_href: &str, +) -> Result, ()> { + let mut nav_file = archive.by_name(nav_href).map_err(|_| ())?; + let mut content = String::new(); + nav_file.read_to_string(&mut content).map_err(|_| ())?; + + // Look for + let toc_nav = content + .find("epub:type=\"toc\"") + .or_else(|| content.find("epub:type='toc'")); + + let Some(nav_pos) = toc_nav else { + return Ok(Vec::new()); + }; + + // Find the
    within this nav + let nav_section = &content[nav_pos..]; + let Some(ol_start) = nav_section.find(" element from EPUB 3 nav document +fn parse_nav_ol(content: &str, base_path: &str) -> Vec { + let mut entries = Vec::new(); + let mut remaining = content; + + while let Some(li_start) = remaining.find("Title + if let Some(a_start) = li_content.find("", "") + .map(|t| strip_html_tags(&t)) + .unwrap_or_default(); + + let full_href = if href.is_empty() || href.starts_with('/') { + href + } else { + format!("{}{}", base_path, href) + }; + + // Check for nested
      (children) + let children = if let Some(ol_pos) = li_content.find(" Option { + let close_tag = format!("", tag); + content.find(&close_tag).map(|pos| pos + close_tag.len()) +} + +/// Extract text between two delimiters +fn extract_between(content: &str, start: &str, end: &str) -> Option { + let s = content.find(start)?; + let after = &content[s + start.len()..]; + let e = after.find(end)?; + Some(after[..e].trim().to_string()) +} + +/// Extract an attribute value from an XML/HTML tag +fn extract_attr(tag: &str, attr: &str) -> Option { + let pattern = format!("{}=\"", attr); + let start = tag.find(&pattern)?; + let after = &tag[start + pattern.len()..]; + let end = after.find('"')?; + Some(after[..end].to_string()) +} + +/// Strip HTML tags from a string, leaving only text content +fn strip_html_tags(input: &str) -> String { + let mut result = String::with_capacity(input.len()); + let mut in_tag = false; + for ch in input.chars() { + match ch { + '<' => in_tag = true, + '>' => in_tag = false, + _ if !in_tag => result.push(ch), + _ => {} + } + } + result.trim().to_string() +} + +/// Rewrite TOC entry hrefs to point to the resource endpoint +fn rewrite_toc_hrefs(entry: WebPubTocEntry, base_url: &str) -> WebPubTocEntry { + WebPubTocEntry { + href: format!( + "{}/resource/{}", + base_url, + encode_resource_path(&entry.href) + ), + title: entry.title, + children: entry + .children + .into_iter() + .map(|child| rewrite_toc_hrefs(child, base_url)) + .collect(), + } +} + +/// Percent-encode a resource path for use in URLs, preserving path separators and common chars +fn encode_resource_path(path: &str) -> String { + // For resource paths, we mostly just need to handle spaces and special chars. + // Keep path separators, alphanumeric, dots, hyphens, and underscores as-is. + let mut result = String::with_capacity(path.len()); + for byte in path.bytes() { + match byte { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' | b'/' | b'#' => { + result.push(byte as char); + } + _ => { + result.push('%'); + result.push_str(&format!("{:02X}", byte)); + } + } + } + result +} + +/// Decode a percent-encoded path +fn percent_decode(path: &str) -> String { + let mut result = Vec::with_capacity(path.len()); + let bytes = path.as_bytes(); + let mut i = 0; + while i < bytes.len() { + if bytes[i] == b'%' + && i + 2 < bytes.len() + && let Ok(byte) = u8::from_str_radix(&path[i + 1..i + 3], 16) + { + result.push(byte); + i += 3; + continue; + } + result.push(bytes[i]); + i += 1; + } + String::from_utf8_lossy(&result).into_owned() +} diff --git a/src/api/routes/komga/handlers/mod.rs b/src/api/routes/komga/handlers/mod.rs index 5bef3276..563acc10 100644 --- a/src/api/routes/komga/handlers/mod.rs +++ b/src/api/routes/komga/handlers/mod.rs @@ -5,6 +5,7 @@ pub mod books; pub mod libraries; +pub mod manifest; pub mod pages; pub mod read_progress; pub mod series; @@ -17,6 +18,7 @@ pub use books::{ get_previous_book, search_books, }; pub use libraries::{get_library, get_library_thumbnail, list_libraries}; +pub use manifest::{get_epub_manifest, get_epub_resource}; pub use pages::{get_page, get_page_thumbnail, list_pages}; pub use read_progress::{ delete_progress, get_progression, mark_series_as_read, mark_series_as_unread, put_progression, @@ -45,6 +47,9 @@ pub use books::{ pub use libraries::{__path_get_library, __path_get_library_thumbnail, __path_list_libraries}; #[doc(hidden)] #[allow(unused_imports)] +pub use manifest::{__path_get_epub_manifest, __path_get_epub_resource}; +#[doc(hidden)] +#[allow(unused_imports)] pub use pages::{__path_get_page, __path_get_page_thumbnail, __path_list_pages}; #[doc(hidden)] #[allow(unused_imports)] diff --git a/src/api/routes/komga/handlers/read_progress.rs b/src/api/routes/komga/handlers/read_progress.rs index 54e71613..8b476840 100644 --- a/src/api/routes/komga/handlers/read_progress.rs +++ b/src/api/routes/komga/handlers/read_progress.rs @@ -365,15 +365,9 @@ pub async fn put_progression( let completed = total_progression >= 0.98 || (book.page_count > 0 && current_page >= book.page_count); - // Store the R2Progression with server-normalized totalProgression - let mut body = body; - if let Some(locator) = body.get_mut("locator") - && let Some(locations) = locator.get_mut("locations") - { - locations["totalProgression"] = serde_json::json!(total_progression); - locations["position"] = serde_json::json!(current_page); - } - + // Store the R2Progression as-is from the client. + // Each client uses its own locator (href + progression/CFI) for navigation. + // The normalized values are only used for internal tracking (current_page, percentage). let json_str = serde_json::to_string(&body) .map_err(|e| ApiError::Internal(format!("Failed to serialize R2Progression: {}", e)))?; diff --git a/src/api/routes/komga/routes/books.rs b/src/api/routes/komga/routes/books.rs index c5cf237c..060fc9d1 100644 --- a/src/api/routes/komga/routes/books.rs +++ b/src/api/routes/komga/routes/books.rs @@ -37,4 +37,12 @@ pub fn routes(_state: Arc) -> Router> { "/books/{book_id}/previous", get(handlers::get_previous_book), ) + .route( + "/books/{book_id}/manifest/epub", + get(handlers::get_epub_manifest), + ) + .route( + "/books/{book_id}/resource/{*resource}", + get(handlers::get_epub_resource), + ) } diff --git a/src/api/routes/v1/handlers/read_progress.rs b/src/api/routes/v1/handlers/read_progress.rs index 77638f37..14de8503 100644 --- a/src/api/routes/v1/handlers/read_progress.rs +++ b/src/api/routes/v1/handlers/read_progress.rs @@ -410,15 +410,9 @@ pub async fn put_progression( let completed = total_progression >= 0.98 || (book.page_count > 0 && current_page >= book.page_count); - // Store with server-normalized totalProgression - let mut body = body; - if let Some(locator) = body.get_mut("locator") - && let Some(locations) = locator.get_mut("locations") - { - locations["totalProgression"] = serde_json::json!(total_progression); - locations["position"] = serde_json::json!(current_page); - } - + // Store the R2Progression as-is from the client. + // Each client uses its own locator (href + progression/CFI) for navigation. + // The normalized values are only used for internal tracking (current_page, percentage). let json_str = serde_json::to_string(&body) .map_err(|e| ApiError::Internal(format!("Failed to serialize R2Progression: {}", e)))?; diff --git a/src/db/repositories/read_progress.rs b/src/db/repositories/read_progress.rs index 6b42ab5c..55907ebe 100644 --- a/src/db/repositories/read_progress.rs +++ b/src/db/repositories/read_progress.rs @@ -136,7 +136,11 @@ impl ReadProgressRepository { active_model.progress_percentage = Set(progress_percentage); active_model.completed = Set(completed); active_model.updated_at = Set(now); - active_model.r2_progression = Set(r2_progression); + // Only update r2_progression if a new value is provided; + // passing None means "don't change", not "clear it" + if r2_progression.is_some() { + active_model.r2_progression = Set(r2_progression); + } // Set completed_at if just marked as completed if completed && existing_model.completed_at.is_none() { diff --git a/src/parsers/epub/parser.rs b/src/parsers/epub/parser.rs index d2055c32..4da09dd5 100644 --- a/src/parsers/epub/parser.rs +++ b/src/parsers/epub/parser.rs @@ -88,7 +88,7 @@ impl EpubParser { } /// Parse the EPUB container.xml to find the root file (usually content.opf) - fn find_root_file(archive: &mut ZipArchive) -> Result { + pub(crate) fn find_root_file(archive: &mut ZipArchive) -> Result { let mut container_file = archive .by_name("META-INF/container.xml") .map_err(|_| CodexError::ParseError("META-INF/container.xml not found".to_string()))?; @@ -160,7 +160,7 @@ impl EpubParser { /// /// Returns (manifest: id -> (href, media_type), spine_order: Vec<(href, media_type)>) #[allow(clippy::type_complexity)] - fn parse_opf( + pub(crate) fn parse_opf( archive: &mut ZipArchive, opf_path: &str, ) -> Result<(HashMap, Vec<(String, String)>)> { diff --git a/web/src/components/reader/EpubReader.tsx b/web/src/components/reader/EpubReader.tsx index 9a6a7ebc..d3bf630d 100644 --- a/web/src/components/reader/EpubReader.tsx +++ b/web/src/components/reader/EpubReader.tsx @@ -169,8 +169,9 @@ export function EpubReader({ const { getSavedLocation, getLocalTimestamp, - initialPercentage, initialCfi, + initialHref, + initialProgression, apiTimestamp, isLoadingProgress, saveLocation, @@ -262,6 +263,7 @@ export function EpubReader({ (state) => state.settings.epubLineHeight, ); const epubMargin = useReaderStore((state) => state.settings.epubMargin); + const epubSpread = useReaderStore((state) => state.settings.epubSpread); // Use refs for initial styles to avoid re-creating handleGetRendition const epubThemeRef = useRef(epubTheme); @@ -344,58 +346,127 @@ export function EpubReader({ } }, [epubMargin]); - // Apply API progress for cross-device sync. - // Compares localStorage timestamp with API (R2Progression) timestamp. - // If API is newer (e.g., progress updated from another device/app), use API data. - // If localStorage is newer or no API data, keep the localStorage position. - // Priority: initialCfi (precise) > initialPercentage (approximate) + // Apply spread mode to rendition + useEffect(() => { + if (renditionRef.current) { + // epub.js spread() accepts "none" (single), "always" (double), or "auto" (responsive) + // For "always", set minSpreadWidth to 0 so it never collapses to single page + const minWidth = epubSpread === "always" ? 0 : 800; + renditionRef.current.spread(epubSpread, minWidth); + } + }, [epubSpread]); + + // Helper: check if API progress is newer than localStorage + const isApiNewer = useCallback(() => { + if (!initialLocationLoadedRef.current) return true; // No local data, always apply + if (!apiTimestamp) return false; + const localTs = getLocalTimestamp(); + if (!localTs) return true; // No local timestamp, prefer API + return new Date(apiTimestamp).getTime() > new Date(localTs).getTime(); + }, [apiTimestamp, getLocalTimestamp]); + + // Apply CFI-based API progress immediately (no need to wait for locations generation). + // This handles cross-device sync when the R2Progression was saved by another Codex web + // instance (which includes a precise CFI). useEffect(() => { if ( - locationsReady && !isLoadingProgress && - (initialCfi !== null || initialPercentage !== null) && + initialCfi !== null && + !hasAppliedApiProgress && + renditionRef.current && + isApiNewer() + ) { + setLocation(initialCfi); + setHasAppliedApiProgress(true); + } + }, [isLoadingProgress, initialCfi, hasAppliedApiProgress, isApiNewer]); + + // Whether we need cross-app sync (Komic/Readium): no CFI, but has href, and API is newer. + // When true, we show a loading spinner until locations are ready for precise positioning. + const needsCrossAppSync = useMemo(() => { + if (isLoadingProgress) return false; + return initialCfi === null && initialHref !== null && isApiNewer(); + }, [isLoadingProgress, initialCfi, initialHref, isApiNewer]); + + // Ref so the relocated callback can check if cross-app sync is pending + const pendingCrossAppSyncRef = useRef(false); + useEffect(() => { + pendingCrossAppSyncRef.current = + needsCrossAppSync && !hasAppliedApiProgress; + }, [needsCrossAppSync, hasAppliedApiProgress]); + + // Cross-app sync: navigate precisely using href + within-resource progression. + // Waits for locations to be generated so we can position accurately within the chapter. + // The loading spinner stays visible until this completes. + useEffect(() => { + if ( + locationsReady && + needsCrossAppSync && !hasAppliedApiProgress && renditionRef.current ) { - // Check if API data is newer than localStorage - let shouldApplyApi = !initialLocationLoadedRef.current; // No local data, always apply - - if (initialLocationLoadedRef.current && apiTimestamp) { - // Both local and API data exist; compare timestamps - const localTs = getLocalTimestamp(); - if (!localTs) { - // No local timestamp (old data before timestamps were stored), prefer API - shouldApplyApi = true; - } else { - const localTime = new Date(localTs).getTime(); - const apiTime = new Date(apiTimestamp).getTime(); - shouldApplyApi = apiTime > localTime; - } - } + const book = renditionRef.current.book; + if (book?.locations?.length()) { + const spine = book.spine as { + items?: Array<{ href: string; cfiBase: string }>; + }; + const spineItem = spine.items?.find( + (item) => + item.href === initialHref || + item.href.endsWith(initialHref!) || + initialHref!.endsWith(item.href), + ); + + if ( + spineItem && + initialProgression !== null && + initialProgression > 0 + ) { + // Interpolate within the section's book-level percentage range + const locations = book.locations; + const total = locations.length(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const allLocs: string[] = (locations as any)._locations ?? []; + + let firstIdx = -1; + let lastIdx = -1; + for (let i = 0; i < allLocs.length; i++) { + if (allLocs[i].includes(spineItem.cfiBase)) { + if (firstIdx === -1) firstIdx = i; + lastIdx = i; + } + } - if (shouldApplyApi) { - if (initialCfi) { - setLocation(initialCfi); - } else if (initialPercentage !== null) { - const book = renditionRef.current.book; - if (book?.locations?.length()) { - const cfi = book.locations.cfiFromPercentage(initialPercentage); + if (firstIdx >= 0 && total > 0) { + const sectionStart = firstIdx / total; + const sectionEnd = (lastIdx + 1) / total; + const targetPct = + sectionStart + initialProgression * (sectionEnd - sectionStart); + const cfi = locations.cfiFromPercentage( + Math.min(targetPct, 0.9999), + ); if (cfi) { setLocation(cfi); } + } else { + // No locations for this section, navigate to href start + setLocation(initialHref!); } + } else { + // progression is 0/null, navigate to start of chapter + setLocation(initialHref!); } } setHasAppliedApiProgress(true); + // Clear the loading spinner now that we've navigated to the right spot + setIsLoading(false); } }, [ locationsReady, - isLoadingProgress, - initialCfi, - initialPercentage, - apiTimestamp, + needsCrossAppSync, + initialHref, + initialProgression, hasAppliedApiProgress, - getLocalTimestamp, ]); // Ref for onClose to keep handleGetRendition stable @@ -453,7 +524,10 @@ export function EpubReader({ // Track current chapter for TOC highlighting and save progress rendition.on("relocated", (location: Location) => { setCurrentHref(location.start.href); - setIsLoading(false); + // Keep spinner visible while waiting for cross-app position sync + if (!pendingCrossAppSyncRef.current) { + setIsLoading(false); + } // Get percentage from book locations using the CFI const cfi = location.start.cfi; @@ -897,6 +971,7 @@ export function EpubReader({ }} epubOptions={{ allowScriptedContent: false, + spread: epubSpread, }} /> diff --git a/web/src/components/reader/EpubReaderSettings.tsx b/web/src/components/reader/EpubReaderSettings.tsx index dabf16e8..2ddcd0f6 100644 --- a/web/src/components/reader/EpubReaderSettings.tsx +++ b/web/src/components/reader/EpubReaderSettings.tsx @@ -12,6 +12,7 @@ import { } from "@mantine/core"; import { type EpubFontFamily, + type EpubSpread, type EpubTheme, useReaderStore, } from "@/store/readerStore"; @@ -39,6 +40,13 @@ const THEME_OPTIONS = [ { value: "forest", label: "Forest" }, ]; +/** Page layout (spread) options */ +const SPREAD_OPTIONS = [ + { value: "auto", label: "Auto (responsive)" }, + { value: "none", label: "Single page" }, + { value: "always", label: "Double page" }, +]; + /** Font family options for display in select */ const FONT_FAMILY_OPTIONS = [ { value: "default", label: "Default" }, @@ -69,6 +77,7 @@ export function EpubReaderSettings({ const setEpubFontFamily = useReaderStore((state) => state.setEpubFontFamily); const setEpubLineHeight = useReaderStore((state) => state.setEpubLineHeight); const setEpubMargin = useReaderStore((state) => state.setEpubMargin); + const setEpubSpread = useReaderStore((state) => state.setEpubSpread); const setAutoHideToolbar = useReaderStore( (state) => state.setAutoHideToolbar, ); @@ -190,6 +199,21 @@ export function EpubReaderSettings({ ]} /> + + {/* Page Layout */} + + + Page Layout + +