From f408b43d9eea4eb0010d242dbeb4f9ed8d78c9ce Mon Sep 17 00:00:00 2001 From: Sylvain Cau Date: Fri, 1 May 2026 13:01:18 -0700 Subject: [PATCH 01/19] feat(metadata): split total_book_count into separate volume and chapter counts Replace the overloaded series_metadata.total_book_count with two semantically distinct fields, total_volume_count (Option) and total_chapter_count (Option), each with its own lock. The single column was ambiguous: it meant volumes for volume-organized libraries and chapters for chapter-organized ones, producing incoherent counts like "109/14" when providers returned volume totals against a local chapter-organized series. This change adds the new schema and supporting plumbing without yet touching read sites, plugin protocol, or DTOs: - Migration m20260502_000067_split_book_count adds the four new columns and backfills total_volume_count + lock from the legacy total_book_count + lock. The legacy column is kept for now and will be dropped in a follow-up once all read sites are migrated. - SeriesMetadata entity gains the four new fields; legacy fields are marked deprecated. All explicit ActiveModel constructors are updated. - SeriesMetadataRepository gains update_total_volume_count and update_total_chapter_count, and set_lock / is_field_locked recognise the new lock keys. - Adds backfill and round-trip tests, including fractional f32 chapter counts and independent lock toggling. --- migration/src/lib.rs | 5 + .../src/m20260502_000067_split_book_count.rs | 136 +++++++++++++ src/api/routes/v1/handlers/series.rs | 4 + src/db/entities/series_metadata.rs | 10 +- src/db/repositories/series.rs | 4 + src/db/repositories/series_metadata.rs | 192 ++++++++++++++++++ tests/db/migrations.rs | 140 +++++++++++++ 7 files changed, 489 insertions(+), 2 deletions(-) create mode 100644 migration/src/m20260502_000067_split_book_count.rs diff --git a/migration/src/lib.rs b/migration/src/lib.rs index 96ec6de8..d7582054 100644 --- a/migration/src/lib.rs +++ b/migration/src/lib.rs @@ -135,6 +135,9 @@ mod m20260408_000064_create_series_exports; mod m20260408_000065_seed_series_export_settings; mod m20260410_000066_add_export_type; +// Split series_metadata.total_book_count into total_volume_count and total_chapter_count +mod m20260502_000067_split_book_count; + pub struct Migrator; #[async_trait::async_trait] @@ -241,6 +244,8 @@ impl MigratorTrait for Migrator { Box::new(m20260408_000064_create_series_exports::Migration), Box::new(m20260408_000065_seed_series_export_settings::Migration), Box::new(m20260410_000066_add_export_type::Migration), + // Split total_book_count into total_volume_count and total_chapter_count + Box::new(m20260502_000067_split_book_count::Migration), ] } } diff --git a/migration/src/m20260502_000067_split_book_count.rs b/migration/src/m20260502_000067_split_book_count.rs new file mode 100644 index 00000000..0a2cb602 --- /dev/null +++ b/migration/src/m20260502_000067_split_book_count.rs @@ -0,0 +1,136 @@ +//! Split `series_metadata.total_book_count` into `total_volume_count` and `total_chapter_count`. +//! +//! Phase 1 of the metadata-count-split plan: adds new columns + locks and backfills +//! the new volume column from the existing single book count, preserving the lock state. +//! The legacy `total_book_count` column stays in place until Phase 9 (hard removal). +//! +//! Why: `total_book_count` is overloaded (volumes, chapters, or whatever). Splitting it +//! lets chapter-organized libraries show real "behind by N" indicators against provider +//! data and lets mixed-format libraries be modeled correctly. + +use sea_orm_migration::prelude::*; +use sea_orm_migration::sea_orm::Statement; + +use crate::m20260103_000006_create_series_metadata::SeriesMetadata; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + // Step 1: add total_volume_count (INTEGER NULL). + manager + .alter_table( + Table::alter() + .table(SeriesMetadata::Table) + .add_column(ColumnDef::new(Alias::new("total_volume_count")).integer()) + .to_owned(), + ) + .await?; + + // Step 2: add total_volume_count_lock (BOOLEAN NOT NULL DEFAULT FALSE). + manager + .alter_table( + Table::alter() + .table(SeriesMetadata::Table) + .add_column( + ColumnDef::new(Alias::new("total_volume_count_lock")) + .boolean() + .not_null() + .default(false), + ) + .to_owned(), + ) + .await?; + + // Step 3: add total_chapter_count (REAL/FLOAT NULL). Chapters can be fractional + // (e.g. 47.5, 100.5) so a float type is required; integer would be lossy. + manager + .alter_table( + Table::alter() + .table(SeriesMetadata::Table) + .add_column(ColumnDef::new(Alias::new("total_chapter_count")).float()) + .to_owned(), + ) + .await?; + + // Step 4: add total_chapter_count_lock (BOOLEAN NOT NULL DEFAULT FALSE). + manager + .alter_table( + Table::alter() + .table(SeriesMetadata::Table) + .add_column( + ColumnDef::new(Alias::new("total_chapter_count_lock")) + .boolean() + .not_null() + .default(false), + ) + .to_owned(), + ) + .await?; + + // Step 5: backfill. Existing total_book_count data is overwhelmingly volume-shaped + // (most providers and library organizations are volume-oriented), so copy values + // and locks into the volume columns. Chapter-organized users who emptied + locked + // total_book_count land on total_volume_count = NULL, lock = true: exactly the + // semantically clean state they wanted. Chapter columns stay NULL/false until + // a future metadata refresh populates them from a provider. + let db = manager.get_connection(); + db.execute(Statement::from_string( + manager.get_database_backend(), + "UPDATE series_metadata + SET total_volume_count = total_book_count, + total_volume_count_lock = total_book_count_lock + WHERE total_book_count IS NOT NULL + OR total_book_count_lock = TRUE" + .to_owned(), + )) + .await?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + // Reverse the column additions in opposite order. No data restore needed: + // the legacy total_book_count column is untouched in up(), so it still holds + // the original values. + manager + .alter_table( + Table::alter() + .table(SeriesMetadata::Table) + .drop_column(Alias::new("total_chapter_count_lock")) + .to_owned(), + ) + .await?; + + manager + .alter_table( + Table::alter() + .table(SeriesMetadata::Table) + .drop_column(Alias::new("total_chapter_count")) + .to_owned(), + ) + .await?; + + manager + .alter_table( + Table::alter() + .table(SeriesMetadata::Table) + .drop_column(Alias::new("total_volume_count_lock")) + .to_owned(), + ) + .await?; + + manager + .alter_table( + Table::alter() + .table(SeriesMetadata::Table) + .drop_column(Alias::new("total_volume_count")) + .to_owned(), + ) + .await?; + + Ok(()) + } +} diff --git a/src/api/routes/v1/handlers/series.rs b/src/api/routes/v1/handlers/series.rs index 0ccbc0da..970019d8 100644 --- a/src/api/routes/v1/handlers/series.rs +++ b/src/api/routes/v1/handlers/series.rs @@ -766,9 +766,13 @@ pub async fn patch_series( reading_direction: Set(None), year: Set(None), total_book_count: Set(None), + total_volume_count: Set(None), + total_chapter_count: Set(None), custom_metadata: Set(None), authors_json: Set(None), total_book_count_lock: Set(false), + total_volume_count_lock: Set(false), + total_chapter_count_lock: Set(false), title_lock: Set(true), // Auto-lock when user edits title_sort_lock: Set(false), summary_lock: Set(false), diff --git a/src/db/entities/series_metadata.rs b/src/db/entities/series_metadata.rs index 416d1751..2da33fa3 100644 --- a/src/db/entities/series_metadata.rs +++ b/src/db/entities/series_metadata.rs @@ -96,13 +96,19 @@ pub struct Model { pub language: Option, // BCP47: "en", "ja", "ko" pub reading_direction: Option, // ltr, rtl, ttb pub year: Option, - pub total_book_count: Option, // Expected total (for ongoing series) + pub total_book_count: Option, // Expected total (for ongoing series). DEPRECATED: removed in Phase 9 of metadata-count-split; new code reads/writes total_volume_count and total_chapter_count instead. + /// Total volumes the series will/did have, when known. Use for volume-organized libraries. + pub total_volume_count: Option, + /// Total chapters the series will/did have, when known. May be fractional (e.g. 47.5). + pub total_chapter_count: Option, pub custom_metadata: Option, // JSON escape hatch for user-defined fields /// Structured author information as JSON array /// Format: [{"name": "...", "role": "author|co-author|editor|...", "sort_name": "..."}] pub authors_json: Option, // Lock fields - pub total_book_count_lock: bool, + pub total_book_count_lock: bool, // DEPRECATED: removed in Phase 9; replaced by total_volume_count_lock + total_chapter_count_lock. + pub total_volume_count_lock: bool, + pub total_chapter_count_lock: bool, pub title_lock: bool, pub title_sort_lock: bool, pub summary_lock: bool, diff --git a/src/db/repositories/series.rs b/src/db/repositories/series.rs index a2c441b0..0ac71b86 100644 --- a/src/db/repositories/series.rs +++ b/src/db/repositories/series.rs @@ -754,10 +754,14 @@ impl SeriesRepository { reading_direction: Set(None), year: Set(None), total_book_count: Set(None), + total_volume_count: Set(None), + total_chapter_count: Set(None), custom_metadata: Set(None), authors_json: Set(None), // Lock fields default to false total_book_count_lock: Set(false), + total_volume_count_lock: Set(false), + total_chapter_count_lock: Set(false), title_lock: Set(false), title_sort_lock: Set(false), summary_lock: Set(false), diff --git a/src/db/repositories/series_metadata.rs b/src/db/repositories/series_metadata.rs index 10dff7a2..6b97ab8a 100644 --- a/src/db/repositories/series_metadata.rs +++ b/src/db/repositories/series_metadata.rs @@ -69,9 +69,13 @@ impl SeriesMetadataRepository { reading_direction: Set(None), year: Set(None), total_book_count: Set(None), + total_volume_count: Set(None), + total_chapter_count: Set(None), custom_metadata: Set(None), authors_json: Set(None), total_book_count_lock: Set(false), + total_volume_count_lock: Set(false), + total_chapter_count_lock: Set(false), title_lock: Set(false), title_sort_lock: Set(false), summary_lock: Set(false), @@ -301,6 +305,10 @@ impl SeriesMetadataRepository { } /// Update total book count (expected number of books in the series) + /// + /// DEPRECATED: kept through Phase 4 of metadata-count-split for the legacy column; + /// new callers should use [`update_total_volume_count`] and/or + /// [`update_total_chapter_count`]. Removed entirely in Phase 9. pub async fn update_total_book_count( db: &DatabaseConnection, series_id: Uuid, @@ -320,6 +328,52 @@ impl SeriesMetadataRepository { Ok(model) } + /// Update the expected total volume count for a series. + /// + /// Pass `None` to clear the value. The lock state is independent and unchanged. + pub async fn update_total_volume_count( + db: &DatabaseConnection, + series_id: Uuid, + total_volume_count: Option, + ) -> Result { + let existing = Self::get_by_series_id(db, series_id) + .await? + .ok_or_else(|| { + anyhow::anyhow!("Series metadata not found for series: {}", series_id) + })?; + + let mut active_model: series_metadata::ActiveModel = existing.into(); + active_model.total_volume_count = Set(total_volume_count); + active_model.updated_at = Set(Utc::now()); + + let model = active_model.update(db).await?; + Ok(model) + } + + /// Update the expected total chapter count for a series. + /// + /// Pass `None` to clear the value. Chapters are stored as `f32` to support + /// fractional chapter numbers like 47.5 that some providers expose. The lock + /// state is independent and unchanged. + pub async fn update_total_chapter_count( + db: &DatabaseConnection, + series_id: Uuid, + total_chapter_count: Option, + ) -> Result { + let existing = Self::get_by_series_id(db, series_id) + .await? + .ok_or_else(|| { + anyhow::anyhow!("Series metadata not found for series: {}", series_id) + })?; + + let mut active_model: series_metadata::ActiveModel = existing.into(); + active_model.total_chapter_count = Set(total_chapter_count); + active_model.updated_at = Set(Utc::now()); + + let model = active_model.update(db).await?; + Ok(model) + } + /// Update authors JSON pub async fn update_authors_json( db: &DatabaseConnection, @@ -367,6 +421,8 @@ impl SeriesMetadataRepository { "reading_direction" => active_model.reading_direction_lock = Set(locked), "year" => active_model.year_lock = Set(locked), "total_book_count" => active_model.total_book_count_lock = Set(locked), + "total_volume_count" => active_model.total_volume_count_lock = Set(locked), + "total_chapter_count" => active_model.total_chapter_count_lock = Set(locked), "genres" => active_model.genres_lock = Set(locked), "tags" => active_model.tags_lock = Set(locked), "cover" => active_model.cover_lock = Set(locked), @@ -393,6 +449,8 @@ impl SeriesMetadataRepository { "reading_direction" => metadata.reading_direction_lock, "year" => metadata.year_lock, "total_book_count" => metadata.total_book_count_lock, + "total_volume_count" => metadata.total_volume_count_lock, + "total_chapter_count" => metadata.total_chapter_count_lock, "genres" => metadata.genres_lock, "tags" => metadata.tags_lock, "cover" => metadata.cover_lock, @@ -664,4 +722,138 @@ mod tests { assert!(!reset.title_lock); assert!(!reset.summary_lock); } + + /// Helper that creates a library + series and returns the series UUID. + async fn make_series(db: &crate::db::Database, name: &str) -> Uuid { + let library = LibraryRepository::create( + db.sea_orm_connection(), + "Test Library", + "/test/path", + ScanningStrategy::Default, + ) + .await + .unwrap(); + SeriesRepository::create(db.sea_orm_connection(), library.id, name, None) + .await + .unwrap() + .id + } + + #[tokio::test] + async fn test_update_total_volume_count_round_trip() { + let (db, _temp_dir) = create_test_db().await; + let series_id = make_series(&db, "Vol Series").await; + + // Defaults: both new fields unset, both locks false. + let initial = + SeriesMetadataRepository::get_by_series_id(db.sea_orm_connection(), series_id) + .await + .unwrap() + .unwrap(); + assert!(initial.total_volume_count.is_none()); + assert!(!initial.total_volume_count_lock); + + // Set volume count. + let updated = SeriesMetadataRepository::update_total_volume_count( + db.sea_orm_connection(), + series_id, + Some(14), + ) + .await + .unwrap(); + assert_eq!(updated.total_volume_count, Some(14)); + // Lock should not have been touched. + assert!(!updated.total_volume_count_lock); + // Chapter side untouched. + assert!(updated.total_chapter_count.is_none()); + + // Clear via None. + let cleared = SeriesMetadataRepository::update_total_volume_count( + db.sea_orm_connection(), + series_id, + None, + ) + .await + .unwrap(); + assert!(cleared.total_volume_count.is_none()); + } + + #[tokio::test] + async fn test_update_total_chapter_count_round_trip_fractional() { + let (db, _temp_dir) = create_test_db().await; + let series_id = make_series(&db, "Chap Series").await; + + // Set fractional chapter count to confirm float storage works. + let updated = SeriesMetadataRepository::update_total_chapter_count( + db.sea_orm_connection(), + series_id, + Some(109.5), + ) + .await + .unwrap(); + assert_eq!(updated.total_chapter_count, Some(109.5)); + assert!(!updated.total_chapter_count_lock); + // Volume side untouched. + assert!(updated.total_volume_count.is_none()); + + // Clear via None. + let cleared = SeriesMetadataRepository::update_total_chapter_count( + db.sea_orm_connection(), + series_id, + None, + ) + .await + .unwrap(); + assert!(cleared.total_chapter_count.is_none()); + } + + #[tokio::test] + async fn test_volume_and_chapter_locks_are_independent() { + let (db, _temp_dir) = create_test_db().await; + let series_id = make_series(&db, "Lock Series").await; + + // Lock volume count, leave chapter count untouched. + let after_vol_lock = SeriesMetadataRepository::set_lock( + db.sea_orm_connection(), + series_id, + "total_volume_count", + true, + ) + .await + .unwrap(); + assert!(after_vol_lock.total_volume_count_lock); + assert!(!after_vol_lock.total_chapter_count_lock); + assert!(SeriesMetadataRepository::is_field_locked( + &after_vol_lock, + "total_volume_count" + )); + assert!(!SeriesMetadataRepository::is_field_locked( + &after_vol_lock, + "total_chapter_count" + )); + + // Lock chapter count too; both locked now. + let after_chap_lock = SeriesMetadataRepository::set_lock( + db.sea_orm_connection(), + series_id, + "total_chapter_count", + true, + ) + .await + .unwrap(); + assert!(after_chap_lock.total_volume_count_lock); + assert!(after_chap_lock.total_chapter_count_lock); + + // Unlock chapter count: volume lock stays. + let after_unlock = SeriesMetadataRepository::set_lock( + db.sea_orm_connection(), + series_id, + "total_chapter_count", + false, + ) + .await + .unwrap(); + assert!(after_unlock.total_volume_count_lock); + assert!(!after_unlock.total_chapter_count_lock); + } } diff --git a/tests/db/migrations.rs b/tests/db/migrations.rs index 9d6a2098..8259f4eb 100644 --- a/tests/db/migrations.rs +++ b/tests/db/migrations.rs @@ -365,3 +365,143 @@ async fn test_migration_056_fresh_run_postgres() { db.close().await; } + +// -- Migration 067 (split_book_count) tests -- +// These tests verify that the migration adds the new volume + chapter columns +// and backfills total_volume_count from the legacy total_book_count, preserving +// the lock state. Chapter columns must remain NULL/false. + +/// Helper: create a SQLite database and run all migrations EXCEPT the last one (067). +/// Returns the Database and TempDir (must keep alive). +async fn setup_db_before_migration_067() -> (Database, TempDir) { + let temp_dir = TempDir::new().unwrap(); + let db_path = temp_dir.path().join("test.db"); + + let config = DatabaseConfig { + db_type: DatabaseType::SQLite, + postgres: None, + sqlite: Some(SQLiteConfig { + path: db_path.to_str().unwrap().to_string(), + pragmas: None, + ..SQLiteConfig::default() + }), + }; + + let db = Database::new(&config).await.unwrap(); + let conn = db.sea_orm_connection(); + + // Run all migrations except the last one (067 = split_book_count). + // Total migrations after adding 067 is 64; running 63 leaves 067 pending. + Migrator::up(conn, Some(63)).await.unwrap(); + + (db, temp_dir) +} + +#[tokio::test] +async fn test_migration_067_backfill_sqlite() { + let (db, _temp_dir) = setup_db_before_migration_067().await; + let conn = db.sea_orm_connection(); + + // Pre-conditions: legacy column exists, new columns do not. + assert!(sqlite_has_column(conn, "series_metadata", "total_book_count").await); + assert!(sqlite_has_column(conn, "series_metadata", "total_book_count_lock").await); + assert!(!sqlite_has_column(conn, "series_metadata", "total_volume_count").await); + assert!(!sqlite_has_column(conn, "series_metadata", "total_volume_count_lock").await); + assert!(!sqlite_has_column(conn, "series_metadata", "total_chapter_count").await); + assert!(!sqlite_has_column(conn, "series_metadata", "total_chapter_count_lock").await); + + // Seed three series + metadata rows covering the lock/value matrix. + conn.execute_unprepared( + "INSERT INTO libraries (id, name, path, series_strategy, book_strategy, number_strategy, default_reading_direction, created_at, updated_at) + VALUES (X'00000000000000000000000000000001', 'Lib', '/lib', 'series_volume', 'filename', 'file_order', 'LEFT_TO_RIGHT', '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z')" + ).await.unwrap(); + + // Three series IDs. + let s_value_and_lock = "X'00000000000000000000000000000010'"; + let s_value_only = "X'00000000000000000000000000000011'"; + let s_lock_only = "X'00000000000000000000000000000012'"; + + for (idx, sid) in [s_value_and_lock, s_value_only, s_lock_only] + .iter() + .enumerate() + { + let sql = format!( + "INSERT INTO series (id, library_id, path, name, normalized_name, created_at, updated_at) + VALUES ({sid}, X'00000000000000000000000000000001', '/path/{idx}', 'Series {idx}', 'series {idx}', '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z')" + ); + conn.execute_unprepared(&sql).await.unwrap(); + } + + // Row 1: count=14, lock=true (volume-organized series with locked count). + conn.execute_unprepared(&format!( + "INSERT INTO series_metadata (series_id, title, total_book_count, total_book_count_lock, created_at, updated_at) + VALUES ({s_value_and_lock}, 'Locked', 14, 1, '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z')" + )).await.unwrap(); + + // Row 2: count=42, lock=false (typical volume-organized series). + conn.execute_unprepared(&format!( + "INSERT INTO series_metadata (series_id, title, total_book_count, total_book_count_lock, created_at, updated_at) + VALUES ({s_value_only}, 'Open', 42, 0, '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z')" + )).await.unwrap(); + + // Row 3: count=NULL, lock=true (chapter-organized series, user emptied + locked). + conn.execute_unprepared(&format!( + "INSERT INTO series_metadata (series_id, title, total_book_count, total_book_count_lock, created_at, updated_at) + VALUES ({s_lock_only}, 'Empty Locked', NULL, 1, '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z')" + )).await.unwrap(); + + // Run migration 067. + Migrator::up(conn, None).await.unwrap(); + + // Post-conditions: new columns present. + assert!(sqlite_has_column(conn, "series_metadata", "total_volume_count").await); + assert!(sqlite_has_column(conn, "series_metadata", "total_volume_count_lock").await); + assert!(sqlite_has_column(conn, "series_metadata", "total_chapter_count").await); + assert!(sqlite_has_column(conn, "series_metadata", "total_chapter_count_lock").await); + // Legacy columns still present (kept until Phase 9). + assert!(sqlite_has_column(conn, "series_metadata", "total_book_count").await); + assert!(sqlite_has_column(conn, "series_metadata", "total_book_count_lock").await); + + // Helper closure to read a single row's split-count state. + let read_state = |sid: &'static str| { + let sql = format!( + "SELECT total_volume_count, total_volume_count_lock, total_chapter_count, total_chapter_count_lock FROM series_metadata WHERE series_id = {sid}" + ); + async move { + let row = conn + .query_one(Statement::from_string(DatabaseBackend::Sqlite, sql)) + .await + .unwrap() + .unwrap(); + let vol: Option = row.try_get("", "total_volume_count").unwrap(); + let vol_lock: bool = row.try_get("", "total_volume_count_lock").unwrap(); + let chap: Option = row.try_get("", "total_chapter_count").unwrap(); + let chap_lock: bool = row.try_get("", "total_chapter_count_lock").unwrap(); + (vol, vol_lock, chap, chap_lock) + } + }; + + // Row 1: value + lock both copy across. + let (vol, vol_lock, chap, chap_lock) = read_state(s_value_and_lock).await; + assert_eq!(vol, Some(14)); + assert!(vol_lock); + assert!(chap.is_none(), "chapter count must stay NULL on backfill"); + assert!(!chap_lock, "chapter lock must stay false on backfill"); + + // Row 2: value copies, lock stays false. + let (vol, vol_lock, chap, chap_lock) = read_state(s_value_only).await; + assert_eq!(vol, Some(42)); + assert!(!vol_lock); + assert!(chap.is_none()); + assert!(!chap_lock); + + // Row 3: NULL + locked → volume NULL + locked (the chapter-organized workaround state + // lands cleanly on the new schema). + let (vol, vol_lock, chap, chap_lock) = read_state(s_lock_only).await; + assert!(vol.is_none()); + assert!(vol_lock); + assert!(chap.is_none()); + assert!(!chap_lock); + + db.close().await; +} From 7f5f53fbbce236aeeabd9ecd3cb7a0829d821d6a Mon Sep 17 00:00:00 2001 From: Sylvain Cau Date: Fri, 1 May 2026 13:22:48 -0700 Subject: [PATCH 02/19] feat(plugins): add total_volume_count and total_chapter_count to plugin protocol Extend PluginSeriesMetadata and UserLibraryEntry with total_volume_count (Option) and total_chapter_count (Option) so providers can return the two semantically distinct counts separately. Legacy total_book_count remains on the wire and is doc-flagged DEPRECATED for one phase of backward-compat with older plugins; it will be removed once the apply pipeline and DTOs migrate. Add MetadataWriteTotalVolumeCount and MetadataWriteTotalChapterCount permissions (metadata:write:total_volume_count and metadata:write:total_chapter_count), wired into as_str/from_str, the all_write_permissions and common_write_permissions lists, and the metadata:write:* wildcard branch in has_permission. Bump PROTOCOL_VERSION from 1.0 to 1.1 (additive minor): 1.0 plugins continue to deserialize cleanly because total_book_count is still in the schema. Tests cover serde round-trip for the new fields, legacy total_book_count deserialization, skip-when-unset behavior, permission round-trip, and wildcard permission grants. --- src/db/entities/plugins.rs | 121 +++++++++++++++++++- src/services/plugin/library.rs | 2 + src/services/plugin/protocol.rs | 146 ++++++++++++++++++++++++- src/services/plugin/recommendations.rs | 4 + tests/services/metadata_apply.rs | 2 + 5 files changed, 267 insertions(+), 8 deletions(-) diff --git a/src/db/entities/plugins.rs b/src/db/entities/plugins.rs index cf15fee4..11fab502 100644 --- a/src/db/entities/plugins.rs +++ b/src/db/entities/plugins.rs @@ -378,9 +378,19 @@ pub enum PluginPermission { /// Update reading direction #[serde(rename = "metadata:write:reading_direction")] MetadataWriteReadingDirection, - /// Update total book count + /// Update total book count. + /// + /// DEPRECATED: kept for one phase of backward-compat. Removed in Phase 9 of the + /// metadata-count-split plan. New plugins should request + /// `metadata:write:total_volume_count` and/or `metadata:write:total_chapter_count`. #[serde(rename = "metadata:write:total_book_count")] MetadataWriteTotalBookCount, + /// Update total volume count + #[serde(rename = "metadata:write:total_volume_count")] + MetadataWriteTotalVolumeCount, + /// Update total chapter count + #[serde(rename = "metadata:write:total_chapter_count")] + MetadataWriteTotalChapterCount, // ========================================================================= // Book-Specific Write Permissions @@ -458,6 +468,10 @@ impl PluginPermission { PluginPermission::MetadataWriteLanguage => "metadata:write:language", PluginPermission::MetadataWriteReadingDirection => "metadata:write:reading_direction", PluginPermission::MetadataWriteTotalBookCount => "metadata:write:total_book_count", + PluginPermission::MetadataWriteTotalVolumeCount => "metadata:write:total_volume_count", + PluginPermission::MetadataWriteTotalChapterCount => { + "metadata:write:total_chapter_count" + } // Book-specific write permissions PluginPermission::MetadataWriteBookType => "metadata:write:book_type", PluginPermission::MetadataWriteSubtitle => "metadata:write:subtitle", @@ -499,6 +513,8 @@ impl PluginPermission { PluginPermission::MetadataWriteLanguage, PluginPermission::MetadataWriteReadingDirection, PluginPermission::MetadataWriteTotalBookCount, + PluginPermission::MetadataWriteTotalVolumeCount, + PluginPermission::MetadataWriteTotalChapterCount, // Book-specific write permissions PluginPermission::MetadataWriteBookType, PluginPermission::MetadataWriteSubtitle, @@ -533,6 +549,8 @@ impl PluginPermission { PluginPermission::MetadataWriteLanguage, PluginPermission::MetadataWriteReadingDirection, PluginPermission::MetadataWriteTotalBookCount, + PluginPermission::MetadataWriteTotalVolumeCount, + PluginPermission::MetadataWriteTotalChapterCount, ] } @@ -580,6 +598,12 @@ impl FromStr for PluginPermission { Ok(PluginPermission::MetadataWriteReadingDirection) } "metadata:write:total_book_count" => Ok(PluginPermission::MetadataWriteTotalBookCount), + "metadata:write:total_volume_count" => { + Ok(PluginPermission::MetadataWriteTotalVolumeCount) + } + "metadata:write:total_chapter_count" => { + Ok(PluginPermission::MetadataWriteTotalChapterCount) + } // Book-specific write permissions "metadata:write:book_type" => Ok(PluginPermission::MetadataWriteBookType), "metadata:write:subtitle" => Ok(PluginPermission::MetadataWriteSubtitle), @@ -693,6 +717,8 @@ impl Model { | PluginPermission::MetadataWriteLanguage | PluginPermission::MetadataWriteReadingDirection | PluginPermission::MetadataWriteTotalBookCount + | PluginPermission::MetadataWriteTotalVolumeCount + | PluginPermission::MetadataWriteTotalChapterCount // Book-specific write permissions | PluginPermission::MetadataWriteBookType | PluginPermission::MetadataWriteSubtitle @@ -919,6 +945,87 @@ mod tests { assert_eq!(perm, PluginPermission::MetadataWriteGenres); } + #[test] + fn test_total_volume_chapter_count_permissions_round_trip() { + // as_str + assert_eq!( + PluginPermission::MetadataWriteTotalVolumeCount.as_str(), + "metadata:write:total_volume_count" + ); + assert_eq!( + PluginPermission::MetadataWriteTotalChapterCount.as_str(), + "metadata:write:total_chapter_count" + ); + + // from_str + assert_eq!( + PluginPermission::from_str("metadata:write:total_volume_count").unwrap(), + PluginPermission::MetadataWriteTotalVolumeCount + ); + assert_eq!( + PluginPermission::from_str("metadata:write:total_chapter_count").unwrap(), + PluginPermission::MetadataWriteTotalChapterCount + ); + + // serde round-trip + let perm = PluginPermission::MetadataWriteTotalVolumeCount; + let json = serde_json::to_string(&perm).unwrap(); + assert_eq!(json, "\"metadata:write:total_volume_count\""); + let parsed: PluginPermission = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed, PluginPermission::MetadataWriteTotalVolumeCount); + + let perm = PluginPermission::MetadataWriteTotalChapterCount; + let json = serde_json::to_string(&perm).unwrap(); + assert_eq!(json, "\"metadata:write:total_chapter_count\""); + let parsed: PluginPermission = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed, PluginPermission::MetadataWriteTotalChapterCount); + } + + #[test] + fn test_metadata_write_all_grants_total_volume_and_chapter_count() { + use chrono::Utc; + let model = Model { + id: Uuid::new_v4(), + name: "test".to_string(), + display_name: "Test".to_string(), + description: None, + plugin_type: "system".to_string(), + command: "node".to_string(), + args: serde_json::json!([]), + env: serde_json::json!({}), + working_directory: None, + permissions: serde_json::json!(["metadata:write:*"]), + scopes: serde_json::json!([]), + library_ids: serde_json::json!([]), + credentials: None, + credential_delivery: "env".to_string(), + config: serde_json::json!({}), + manifest: None, + enabled: true, + health_status: "healthy".to_string(), + failure_count: 0, + last_failure_at: None, + last_success_at: None, + disabled_reason: None, + rate_limit_requests_per_minute: Some(60), + search_query_template: None, + search_preprocessing_rules: None, + auto_match_conditions: None, + use_existing_external_id: true, + metadata_targets: None, + internal_config: None, + created_at: Utc::now(), + updated_at: Utc::now(), + created_by: None, + updated_by: None, + }; + + assert!(model.has_permission(&PluginPermission::MetadataWriteTotalVolumeCount)); + assert!(model.has_permission(&PluginPermission::MetadataWriteTotalChapterCount)); + // Sanity: still grants the legacy permission (kept until Phase 9). + assert!(model.has_permission(&PluginPermission::MetadataWriteTotalBookCount)); + } + #[test] fn test_all_write_permissions() { let perms = PluginPermission::all_write_permissions(); @@ -934,8 +1041,10 @@ mod tests { assert!(!perms.contains(&PluginPermission::MetadataWriteAll)); assert!(!perms.contains(&PluginPermission::MetadataRead)); assert!(perms.contains(&PluginPermission::MetadataWriteExternalIds)); - // Should have 27 write permissions (15 common + 12 book-specific) - assert_eq!(perms.len(), 27); + assert!(perms.contains(&PluginPermission::MetadataWriteTotalVolumeCount)); + assert!(perms.contains(&PluginPermission::MetadataWriteTotalChapterCount)); + // Should have 29 write permissions (17 common + 12 book-specific) + assert_eq!(perms.len(), 29); } #[test] @@ -944,12 +1053,14 @@ mod tests { assert!(perms.contains(&PluginPermission::MetadataWriteTitle)); assert!(perms.contains(&PluginPermission::MetadataWriteSummary)); assert!(perms.contains(&PluginPermission::MetadataWriteTotalBookCount)); + assert!(perms.contains(&PluginPermission::MetadataWriteTotalVolumeCount)); + assert!(perms.contains(&PluginPermission::MetadataWriteTotalChapterCount)); // Book-specific should NOT be in common assert!(!perms.contains(&PluginPermission::MetadataWriteBookType)); assert!(!perms.contains(&PluginPermission::MetadataWriteIsbn)); assert!(perms.contains(&PluginPermission::MetadataWriteExternalIds)); - // Should have 15 common permissions - assert_eq!(perms.len(), 15); + // Should have 17 common permissions + assert_eq!(perms.len(), 17); } #[test] diff --git a/src/services/plugin/library.rs b/src/services/plugin/library.rs index 717b8d03..c9205ae4 100644 --- a/src/services/plugin/library.rs +++ b/src/services/plugin/library.rs @@ -170,6 +170,8 @@ pub async fn build_user_library( genres, tags, total_book_count: meta.and_then(|m| m.total_book_count), + total_volume_count: meta.and_then(|m| m.total_volume_count), + total_chapter_count: meta.and_then(|m| m.total_chapter_count), external_ids, reading_status, books_read, diff --git a/src/services/plugin/protocol.rs b/src/services/plugin/protocol.rs index 90521e19..f70ebd21 100644 --- a/src/services/plugin/protocol.rs +++ b/src/services/plugin/protocol.rs @@ -14,8 +14,14 @@ use serde_json::Value; pub const JSONRPC_VERSION: &str = "2.0"; /// Plugin protocol version +/// +/// Bumped to 1.1 (additive minor) when `total_volume_count` + `total_chapter_count` +/// were added to `PluginSeriesMetadata` / `UserLibraryEntry` and +/// `MetadataWriteTotalVolumeCount` / `MetadataWriteTotalChapterCount` permissions were +/// introduced. Plugins built against 1.0 still deserialize cleanly (legacy +/// `total_book_count` remains in the schema). #[allow(dead_code)] // Protocol contract: sent to plugins during initialize -pub const PROTOCOL_VERSION: &str = "1.0"; +pub const PROTOCOL_VERSION: &str = "1.1"; // ============================================================================= // JSON-RPC Base Types @@ -764,9 +770,21 @@ pub struct PluginSeriesMetadata { pub year: Option, // Extended metadata - /// Expected total number of books in the series + /// Expected total number of books in the series. + /// + /// DEPRECATED: kept for one phase of backward-compat with older plugins. Plugins + /// should populate `total_volume_count` and/or `total_chapter_count` instead. + /// Removed in Phase 9 of the metadata-count-split plan. #[serde(default, skip_serializing_if = "Option::is_none")] pub total_book_count: Option, + /// Expected total number of volumes in the series, when known. + /// Use this for volume-organized libraries. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub total_volume_count: Option, + /// Expected total number of chapters in the series, when known. May be fractional. + /// Use this for chapter-organized libraries. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub total_chapter_count: Option, /// BCP47 language code (e.g., "en", "ja", "ko") #[serde(default, skip_serializing_if = "Option::is_none")] pub language: Option, @@ -1186,9 +1204,19 @@ pub struct UserLibraryEntry { /// Tags #[serde(default, skip_serializing_if = "Vec::is_empty")] pub tags: Vec, - /// Total book count in the series + /// Total book count in the series. + /// + /// DEPRECATED: kept for one phase of backward-compat with older plugins. Plugins + /// should consume `total_volume_count` and/or `total_chapter_count` instead. + /// Removed in Phase 9 of the metadata-count-split plan. #[serde(default, skip_serializing_if = "Option::is_none")] pub total_book_count: Option, + /// Expected total number of volumes in the series, when known. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub total_volume_count: Option, + /// Expected total number of chapters in the series, when known. May be fractional. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub total_chapter_count: Option, /// Known external IDs (source → external_id mapping) /// e.g., {"anilist": "12345", "myanimelist": "67890"} @@ -1461,6 +1489,8 @@ mod tests { status: Some(SeriesStatus::Ongoing), year: Some(1997), total_book_count: Some(100), + total_volume_count: Some(100), + total_chapter_count: Some(1086.0), language: Some("ja".to_string()), age_rating: Some(13), reading_direction: Some("rtl".to_string()), @@ -1538,6 +1568,8 @@ mod tests { status: None, year: None, total_book_count: None, + total_volume_count: None, + total_chapter_count: None, language: None, age_rating: None, reading_direction: None, @@ -1558,6 +1590,108 @@ mod tests { assert!(!json.as_object().unwrap().contains_key("externalIds")); } + #[test] + fn test_plugin_series_metadata_split_counts_round_trip() { + // Both volume and chapter counts populate cleanly. + let json = json!({ + "externalId": "abc", + "externalUrl": "https://example.com/series/abc", + "title": "One Piece", + "totalVolumeCount": 14, + "totalChapterCount": 109.5, + }); + let parsed: PluginSeriesMetadata = serde_json::from_value(json).unwrap(); + assert_eq!(parsed.total_volume_count, Some(14)); + assert_eq!(parsed.total_chapter_count, Some(109.5)); + // Legacy field is None when only the new fields are sent. + assert_eq!(parsed.total_book_count, None); + + // Round-trip back to JSON preserves both fields. + let serialized = serde_json::to_value(&parsed).unwrap(); + assert_eq!(serialized["totalVolumeCount"], 14); + assert_eq!(serialized["totalChapterCount"], 109.5); + } + + #[test] + fn test_plugin_series_metadata_legacy_total_book_count_still_deserializes() { + // An old plugin that only emits the legacy field must still parse cleanly, + // with the new fields absent (None). The Phase 4 fallback in MetadataApplier + // is what routes this to total_volume_count at write time. + let json = json!({ + "externalId": "old-1", + "externalUrl": "https://example.com/old-1", + "totalBookCount": 14, + }); + let parsed: PluginSeriesMetadata = serde_json::from_value(json).unwrap(); + assert_eq!(parsed.total_book_count, Some(14)); + assert_eq!(parsed.total_volume_count, None); + assert_eq!(parsed.total_chapter_count, None); + } + + #[test] + fn test_plugin_series_metadata_skips_unset_count_fields() { + // When all three count fields are None, none should be present on the wire. + let metadata = PluginSeriesMetadata { + external_id: "1".to_string(), + external_url: "https://example.com/1".to_string(), + title: None, + alternate_titles: vec![], + summary: None, + status: None, + year: None, + total_book_count: None, + total_volume_count: None, + total_chapter_count: None, + language: None, + age_rating: None, + reading_direction: None, + genres: vec![], + tags: vec![], + authors: vec![], + artists: vec![], + publisher: None, + cover_url: None, + banner_url: None, + rating: None, + external_ratings: vec![], + external_links: vec![], + external_ids: vec![], + }; + let json = serde_json::to_value(&metadata).unwrap(); + let obj = json.as_object().unwrap(); + assert!(!obj.contains_key("totalBookCount")); + assert!(!obj.contains_key("totalVolumeCount")); + assert!(!obj.contains_key("totalChapterCount")); + } + + #[test] + fn test_user_library_entry_split_counts_round_trip() { + let json = json!({ + "seriesId": "uuid-1", + "title": "One Piece", + "totalVolumeCount": 107, + "totalChapterCount": 1086.5, + "booksRead": 0, + "booksOwned": 0, + }); + let parsed: UserLibraryEntry = serde_json::from_value(json).unwrap(); + assert_eq!(parsed.total_volume_count, Some(107)); + assert_eq!(parsed.total_chapter_count, Some(1086.5)); + assert_eq!(parsed.total_book_count, None); + + let serialized = serde_json::to_value(&parsed).unwrap(); + assert_eq!(serialized["totalVolumeCount"], 107); + assert_eq!(serialized["totalChapterCount"], 1086.5); + } + + #[test] + fn test_protocol_version_is_minor_bumped() { + // Phase 2 of metadata-count-split bumps the protocol from 1.0 to 1.1 + // (additive minor). Older 1.0 plugins continue to deserialize because + // total_book_count is still on the wire. + assert_eq!(PROTOCOL_VERSION, "1.1"); + } + #[test] fn test_credential_field() { let field = CredentialField { @@ -2090,6 +2224,8 @@ mod tests { genres: vec!["Action".to_string(), "Adventure".to_string()], tags: vec!["pirates".to_string()], total_book_count: Some(107), + total_volume_count: Some(107), + total_chapter_count: Some(1086.5), external_ids: vec![UserLibraryExternalId { source: "anilist".to_string(), external_id: "21".to_string(), @@ -2133,6 +2269,8 @@ mod tests { genres: vec![], tags: vec![], total_book_count: None, + total_volume_count: None, + total_chapter_count: None, external_ids: vec![], reading_status: None, books_read: 0, @@ -2237,6 +2375,8 @@ mod tests { genres: vec![], tags: vec![], total_book_count: None, + total_volume_count: None, + total_chapter_count: None, external_ids: vec![ UserLibraryExternalId { source: "anilist".to_string(), diff --git a/src/services/plugin/recommendations.rs b/src/services/plugin/recommendations.rs index 56442857..d8dbd88b 100644 --- a/src/services/plugin/recommendations.rs +++ b/src/services/plugin/recommendations.rs @@ -258,6 +258,8 @@ mod tests { genres: vec!["Action".to_string(), "Dark Fantasy".to_string()], tags: vec![], total_book_count: Some(41), + total_volume_count: Some(41), + total_chapter_count: None, external_ids: vec![], reading_status: None, books_read: 41, @@ -506,6 +508,8 @@ mod tests { genres: vec!["Adventure".to_string()], tags: vec![], total_book_count: None, + total_volume_count: None, + total_chapter_count: None, external_ids: vec![], reading_status: None, books_read: 100, diff --git a/tests/services/metadata_apply.rs b/tests/services/metadata_apply.rs index 57ef5fd8..3fdd0f0d 100644 --- a/tests/services/metadata_apply.rs +++ b/tests/services/metadata_apply.rs @@ -68,6 +68,8 @@ fn create_test_metadata(title: &str) -> PluginSeriesMetadata { status: None, year: None, total_book_count: None, + total_volume_count: None, + total_chapter_count: None, language: None, age_rating: None, reading_direction: None, From 0dd50b8981ba150a77e5a7e1d650ced43cd2d33d Mon Sep 17 00:00:00 2001 From: Sylvain Cau Date: Fri, 1 May 2026 13:31:32 -0700 Subject: [PATCH 03/19] feat(metadata): apply total_volume_count and total_chapter_count via MetadataApplier Wire writes for the two new count fields through the existing MetadataApplier pipeline so plugins can populate them end-to-end. Each new field reuses the established allowlist + lock + permission + repo-update + applied/skipped tracking pattern, with no logic divergence: - totalVolumeCount: gated by MetadataWriteTotalVolumeCount and total_volume_count_lock; writes via update_total_volume_count. - totalChapterCount: gated by MetadataWriteTotalChapterCount and total_chapter_count_lock; writes via update_total_chapter_count. The legacy totalBookCount block is left in place and now carries a DEPRECATED doc-comment marking it for backward-compat-only writes; it will be replaced by a fallback that routes legacy plugin payloads to totalVolumeCount, and removed when total_book_count is dropped from the schema. Adds integration tests covering happy-path writes, fractional chapter round-trips, both-fields-at-once, lock skipping with reason, permission denial, allowlist filtering, and absent-value no-op behavior. --- src/services/metadata/apply.rs | 57 ++++ tests/services/metadata_apply.rs | 518 +++++++++++++++++++++++++++++++ 2 files changed, 575 insertions(+) diff --git a/src/services/metadata/apply.rs b/src/services/metadata/apply.rs index a7ef4012..fa9367f4 100644 --- a/src/services/metadata/apply.rs +++ b/src/services/metadata/apply.rs @@ -337,6 +337,11 @@ impl MetadataApplier { } // Total Book Count + // + // DEPRECATED: kept through Phase 3 of metadata-count-split for backward + // compatibility with plugins that still emit the legacy field. Phase 4 + // stops the write here and adds a fallback that routes the legacy value + // to `totalVolumeCount`. Phase 9 removes this block entirely. if should_apply_field("totalBookCount") && let Some(total_book_count) = metadata.total_book_count { @@ -362,6 +367,58 @@ impl MetadataApplier { } } + // Total Volume Count + if should_apply_field("totalVolumeCount") + && let Some(total_volume_count) = metadata.total_volume_count + { + let is_locked = current_metadata + .map(|m| m.total_volume_count_lock) + .unwrap_or(false); + match check_field( + "totalVolumeCount", + is_locked, + PluginPermission::MetadataWriteTotalVolumeCount, + ) { + Ok(_) => { + SeriesMetadataRepository::update_total_volume_count( + db, + series_id, + Some(total_volume_count), + ) + .await + .context("Failed to update total volume count")?; + applied_fields.push("totalVolumeCount".to_string()); + } + Err(skip) => skipped_fields.push(skip), + } + } + + // Total Chapter Count + if should_apply_field("totalChapterCount") + && let Some(total_chapter_count) = metadata.total_chapter_count + { + let is_locked = current_metadata + .map(|m| m.total_chapter_count_lock) + .unwrap_or(false); + match check_field( + "totalChapterCount", + is_locked, + PluginPermission::MetadataWriteTotalChapterCount, + ) { + Ok(_) => { + SeriesMetadataRepository::update_total_chapter_count( + db, + series_id, + Some(total_chapter_count), + ) + .await + .context("Failed to update total chapter count")?; + applied_fields.push("totalChapterCount".to_string()); + } + Err(skip) => skipped_fields.push(skip), + } + } + // Genres - uses set_genres_for_series which replaces all if should_apply_field("genres") && !metadata.genres.is_empty() { let is_locked = current_metadata.map(|m| m.genres_lock).unwrap_or(false); diff --git a/tests/services/metadata_apply.rs b/tests/services/metadata_apply.rs index 3fdd0f0d..bc07fc9b 100644 --- a/tests/services/metadata_apply.rs +++ b/tests/services/metadata_apply.rs @@ -16,6 +16,7 @@ use codex::services::plugin::PluginSeriesMetadata; use common::db::setup_test_db; use sea_orm::{ActiveModelTrait, Set}; use serde_json::json; +use std::collections::HashSet; use uuid::Uuid; /// Create a test plugin with title write permission @@ -288,3 +289,520 @@ async fn test_apply_title_sets_title_sort_when_none() { "title_sort should be set when it was None" ); } + +// ============================================================================= +// total_volume_count / total_chapter_count Apply Tests (Phase 3) +// ============================================================================= + +/// Build a plugin with the given permission strings (e.g. "metadata:write:total_volume_count"). +fn create_plugin_with_permissions(permissions: &[&str]) -> plugins::Model { + plugins::Model { + id: Uuid::new_v4(), + name: "test-plugin-counts".to_string(), + display_name: "Test Plugin Counts".to_string(), + description: None, + plugin_type: "system".to_string(), + command: "node".to_string(), + args: json!([]), + env: json!({}), + working_directory: None, + permissions: json!(permissions), + scopes: json!(["series:detail"]), + library_ids: json!([]), + credentials: None, + credential_delivery: "env".to_string(), + config: json!({}), + manifest: None, + enabled: true, + health_status: "healthy".to_string(), + failure_count: 0, + last_failure_at: None, + last_success_at: None, + disabled_reason: None, + rate_limit_requests_per_minute: None, + search_query_template: None, + search_preprocessing_rules: None, + auto_match_conditions: None, + use_existing_external_id: true, + metadata_targets: None, + internal_config: None, + created_at: Utc::now(), + updated_at: Utc::now(), + created_by: None, + updated_by: None, + } +} + +fn metadata_with_counts( + total_volume_count: Option, + total_chapter_count: Option, +) -> PluginSeriesMetadata { + PluginSeriesMetadata { + external_id: "counts-1".to_string(), + external_url: "https://example.com/counts-1".to_string(), + title: None, + alternate_titles: vec![], + summary: None, + status: None, + year: None, + total_book_count: None, + total_volume_count, + total_chapter_count, + language: None, + age_rating: None, + reading_direction: None, + genres: vec![], + tags: vec![], + authors: vec![], + artists: vec![], + publisher: None, + cover_url: None, + banner_url: None, + rating: None, + external_ratings: vec![], + external_links: vec![], + external_ids: vec![], + } +} + +#[tokio::test] +async fn test_apply_total_volume_count_writes_value() { + let (db, _temp_dir) = setup_test_db().await; + let library = + LibraryRepository::create(&db, "Test Library", "/test/path", ScanningStrategy::Default) + .await + .unwrap(); + let series = SeriesRepository::create(&db, library.id, "Series", None) + .await + .unwrap(); + let current = SeriesMetadataRepository::get_by_series_id(&db, series.id) + .await + .unwrap() + .unwrap(); + + let plugin = create_plugin_with_permissions(&["metadata:write:total_volume_count"]); + let result = MetadataApplier::apply( + &db, + series.id, + library.id, + &plugin, + &metadata_with_counts(Some(14), None), + Some(¤t), + &ApplyOptions::default(), + ) + .await + .unwrap(); + + assert!( + result + .applied_fields + .contains(&"totalVolumeCount".to_string()), + "totalVolumeCount should be applied" + ); + let updated = SeriesMetadataRepository::get_by_series_id(&db, series.id) + .await + .unwrap() + .unwrap(); + assert_eq!(updated.total_volume_count, Some(14)); + assert!(updated.total_chapter_count.is_none()); +} + +#[tokio::test] +async fn test_apply_total_chapter_count_writes_fractional_value() { + let (db, _temp_dir) = setup_test_db().await; + let library = + LibraryRepository::create(&db, "Test Library", "/test/path", ScanningStrategy::Default) + .await + .unwrap(); + let series = SeriesRepository::create(&db, library.id, "Series", None) + .await + .unwrap(); + let current = SeriesMetadataRepository::get_by_series_id(&db, series.id) + .await + .unwrap() + .unwrap(); + + let plugin = create_plugin_with_permissions(&["metadata:write:total_chapter_count"]); + let result = MetadataApplier::apply( + &db, + series.id, + library.id, + &plugin, + &metadata_with_counts(None, Some(109.5)), + Some(¤t), + &ApplyOptions::default(), + ) + .await + .unwrap(); + + assert!( + result + .applied_fields + .contains(&"totalChapterCount".to_string()), + "totalChapterCount should be applied" + ); + let updated = SeriesMetadataRepository::get_by_series_id(&db, series.id) + .await + .unwrap() + .unwrap(); + assert_eq!(updated.total_chapter_count, Some(109.5)); + assert!(updated.total_volume_count.is_none()); +} + +#[tokio::test] +async fn test_apply_writes_both_count_fields_independently() { + let (db, _temp_dir) = setup_test_db().await; + let library = + LibraryRepository::create(&db, "Test Library", "/test/path", ScanningStrategy::Default) + .await + .unwrap(); + let series = SeriesRepository::create(&db, library.id, "Series", None) + .await + .unwrap(); + let current = SeriesMetadataRepository::get_by_series_id(&db, series.id) + .await + .unwrap() + .unwrap(); + + let plugin = create_plugin_with_permissions(&[ + "metadata:write:total_volume_count", + "metadata:write:total_chapter_count", + ]); + let result = MetadataApplier::apply( + &db, + series.id, + library.id, + &plugin, + &metadata_with_counts(Some(14), Some(109.0)), + Some(¤t), + &ApplyOptions::default(), + ) + .await + .unwrap(); + + assert!( + result + .applied_fields + .contains(&"totalVolumeCount".to_string()) + ); + assert!( + result + .applied_fields + .contains(&"totalChapterCount".to_string()) + ); + let updated = SeriesMetadataRepository::get_by_series_id(&db, series.id) + .await + .unwrap() + .unwrap(); + assert_eq!(updated.total_volume_count, Some(14)); + assert_eq!(updated.total_chapter_count, Some(109.0)); +} + +#[tokio::test] +async fn test_apply_total_volume_count_skipped_when_locked() { + let (db, _temp_dir) = setup_test_db().await; + let library = + LibraryRepository::create(&db, "Test Library", "/test/path", ScanningStrategy::Default) + .await + .unwrap(); + let series = SeriesRepository::create(&db, library.id, "Series", None) + .await + .unwrap(); + let metadata = SeriesMetadataRepository::get_by_series_id(&db, series.id) + .await + .unwrap() + .unwrap(); + let mut active: series_metadata::ActiveModel = metadata.into(); + active.total_volume_count = Set(Some(7)); + active.total_volume_count_lock = Set(true); + active.update(&db).await.unwrap(); + + let current = SeriesMetadataRepository::get_by_series_id(&db, series.id) + .await + .unwrap() + .unwrap(); + + let plugin = create_plugin_with_permissions(&["metadata:write:total_volume_count"]); + let result = MetadataApplier::apply( + &db, + series.id, + library.id, + &plugin, + &metadata_with_counts(Some(14), None), + Some(¤t), + &ApplyOptions::default(), + ) + .await + .unwrap(); + + assert!( + !result + .applied_fields + .contains(&"totalVolumeCount".to_string()), + "totalVolumeCount should not be applied when locked" + ); + let skipped = result + .skipped_fields + .iter() + .find(|s| s.field == "totalVolumeCount") + .expect("totalVolumeCount should be in skipped_fields"); + assert_eq!(skipped.reason, "Field is locked"); + + let after = SeriesMetadataRepository::get_by_series_id(&db, series.id) + .await + .unwrap() + .unwrap(); + assert_eq!( + after.total_volume_count, + Some(7), + "locked value should be preserved" + ); +} + +#[tokio::test] +async fn test_apply_total_chapter_count_skipped_when_locked() { + let (db, _temp_dir) = setup_test_db().await; + let library = + LibraryRepository::create(&db, "Test Library", "/test/path", ScanningStrategy::Default) + .await + .unwrap(); + let series = SeriesRepository::create(&db, library.id, "Series", None) + .await + .unwrap(); + let metadata = SeriesMetadataRepository::get_by_series_id(&db, series.id) + .await + .unwrap() + .unwrap(); + let mut active: series_metadata::ActiveModel = metadata.into(); + active.total_chapter_count = Set(Some(50.0)); + active.total_chapter_count_lock = Set(true); + active.update(&db).await.unwrap(); + + let current = SeriesMetadataRepository::get_by_series_id(&db, series.id) + .await + .unwrap() + .unwrap(); + + let plugin = create_plugin_with_permissions(&["metadata:write:total_chapter_count"]); + let result = MetadataApplier::apply( + &db, + series.id, + library.id, + &plugin, + &metadata_with_counts(None, Some(109.5)), + Some(¤t), + &ApplyOptions::default(), + ) + .await + .unwrap(); + + assert!( + !result + .applied_fields + .contains(&"totalChapterCount".to_string()), + "totalChapterCount should not be applied when locked" + ); + let skipped = result + .skipped_fields + .iter() + .find(|s| s.field == "totalChapterCount") + .expect("totalChapterCount should be in skipped_fields"); + assert_eq!(skipped.reason, "Field is locked"); + + let after = SeriesMetadataRepository::get_by_series_id(&db, series.id) + .await + .unwrap() + .unwrap(); + assert_eq!( + after.total_chapter_count, + Some(50.0), + "locked value should be preserved" + ); +} + +#[tokio::test] +async fn test_apply_count_fields_skipped_when_permission_missing() { + let (db, _temp_dir) = setup_test_db().await; + let library = + LibraryRepository::create(&db, "Test Library", "/test/path", ScanningStrategy::Default) + .await + .unwrap(); + let series = SeriesRepository::create(&db, library.id, "Series", None) + .await + .unwrap(); + let current = SeriesMetadataRepository::get_by_series_id(&db, series.id) + .await + .unwrap() + .unwrap(); + + // Plugin holds no count permissions. + let plugin = create_plugin_with_permissions(&["metadata:write:title"]); + let result = MetadataApplier::apply( + &db, + series.id, + library.id, + &plugin, + &metadata_with_counts(Some(14), Some(109.5)), + Some(¤t), + &ApplyOptions::default(), + ) + .await + .unwrap(); + + assert!( + !result + .applied_fields + .contains(&"totalVolumeCount".to_string()) + ); + assert!( + !result + .applied_fields + .contains(&"totalChapterCount".to_string()) + ); + let denied: Vec<&str> = result + .skipped_fields + .iter() + .filter(|s| s.field == "totalVolumeCount" || s.field == "totalChapterCount") + .map(|s| s.reason.as_str()) + .collect(); + assert_eq!(denied.len(), 2); + assert!( + denied + .iter() + .all(|r| *r == "Plugin does not have permission") + ); + + let after = SeriesMetadataRepository::get_by_series_id(&db, series.id) + .await + .unwrap() + .unwrap(); + assert!(after.total_volume_count.is_none()); + assert!(after.total_chapter_count.is_none()); +} + +#[tokio::test] +async fn test_apply_count_fields_filtered_out_by_allowlist() { + let (db, _temp_dir) = setup_test_db().await; + let library = + LibraryRepository::create(&db, "Test Library", "/test/path", ScanningStrategy::Default) + .await + .unwrap(); + let series = SeriesRepository::create(&db, library.id, "Series", None) + .await + .unwrap(); + let current = SeriesMetadataRepository::get_by_series_id(&db, series.id) + .await + .unwrap() + .unwrap(); + + let plugin = create_plugin_with_permissions(&[ + "metadata:write:total_volume_count", + "metadata:write:total_chapter_count", + ]); + // Allowlist only totalVolumeCount; totalChapterCount must not be touched. + let mut filter = HashSet::new(); + filter.insert("totalVolumeCount".to_string()); + let options = ApplyOptions { + fields_filter: Some(filter), + thumbnail_service: None, + event_broadcaster: None, + }; + + let result = MetadataApplier::apply( + &db, + series.id, + library.id, + &plugin, + &metadata_with_counts(Some(14), Some(109.5)), + Some(¤t), + &options, + ) + .await + .unwrap(); + + assert!( + result + .applied_fields + .contains(&"totalVolumeCount".to_string()) + ); + assert!( + !result + .applied_fields + .contains(&"totalChapterCount".to_string()), + "totalChapterCount should be filtered out by allowlist" + ); + assert!( + !result + .skipped_fields + .iter() + .any(|s| s.field == "totalChapterCount"), + "filtered-out fields should not appear in skipped_fields either" + ); + + let after = SeriesMetadataRepository::get_by_series_id(&db, series.id) + .await + .unwrap() + .unwrap(); + assert_eq!(after.total_volume_count, Some(14)); + assert!(after.total_chapter_count.is_none()); +} + +#[tokio::test] +async fn test_apply_count_fields_skip_when_metadata_value_absent() { + let (db, _temp_dir) = setup_test_db().await; + let library = + LibraryRepository::create(&db, "Test Library", "/test/path", ScanningStrategy::Default) + .await + .unwrap(); + let series = SeriesRepository::create(&db, library.id, "Series", None) + .await + .unwrap(); + let metadata = SeriesMetadataRepository::get_by_series_id(&db, series.id) + .await + .unwrap() + .unwrap(); + let mut active: series_metadata::ActiveModel = metadata.into(); + active.total_volume_count = Set(Some(3)); + active.total_chapter_count = Set(Some(42.0)); + active.update(&db).await.unwrap(); + let current = SeriesMetadataRepository::get_by_series_id(&db, series.id) + .await + .unwrap() + .unwrap(); + + let plugin = create_plugin_with_permissions(&[ + "metadata:write:total_volume_count", + "metadata:write:total_chapter_count", + ]); + // Both incoming values are None -> mirroring the existing `Some(...)`-gated + // pattern, the apply step does not touch the columns. + let result = MetadataApplier::apply( + &db, + series.id, + library.id, + &plugin, + &metadata_with_counts(None, None), + Some(¤t), + &ApplyOptions::default(), + ) + .await + .unwrap(); + + assert!( + !result + .applied_fields + .contains(&"totalVolumeCount".to_string()) + ); + assert!( + !result + .applied_fields + .contains(&"totalChapterCount".to_string()) + ); + + let after = SeriesMetadataRepository::get_by_series_id(&db, series.id) + .await + .unwrap() + .unwrap(); + assert_eq!(after.total_volume_count, Some(3)); + assert_eq!(after.total_chapter_count, Some(42.0)); +} From 4a28c79c31ace598bf94c20891d2aff27cbb3422 Mon Sep 17 00:00:00 2001 From: Sylvain Cau Date: Fri, 1 May 2026 14:09:13 -0700 Subject: [PATCH 04/19] refactor(metadata): switch read sites to total_volume_count and stop writing total_book_count MetadataApplier no longer writes the legacy total_book_count column. A backward-compat fallback routes legacy `total_book_count` payloads from older plugins into `total_volume_count`, with a one-time-per-plugin deprecation warning. The legacy column is now frozen. Switch every internal read of total_book_count to total_volume_count (and surface total_chapter_count where applicable): - services: completion filter, export collector (new ExpectedChapterCount field), recommendations protocol, plugin library entries, preprocessing context (new totalVolumeCount / totalChapterCount template field-access paths), user-plugin-sync push (now also surfaces total_chapters on SyncProgress). - v1 API handlers: series, recommendations, plugin_actions (separate totalVolumeCount and totalChapterCount previews), bulk_metadata. - Komga compatibility: handler maps the volume-shaped totalBookCount and the oneshot heuristic from total_volume_count. DTOs gain total_volume_count and total_chapter_count (and their lock counterparts) alongside the legacy fields. Both are populated for one phase to give clients and plugins a window to migrate; the legacy fields and the database column will be dropped in a later phase. PUT, PATCH, and lock-update handlers prefer the new fields and fall back to the legacy field when only it is provided. Tests cover the legacy fallback, new-field precedence, and the frozen-column invariant; pre-existing fixtures that seeded total_book_count are updated to use total_volume_count. --- docs/api/openapi.json | 200 ++++++++++++++++-- src/api/routes/komga/handlers/series.rs | 10 +- src/api/routes/v1/dto/bulk_metadata.rs | 16 +- src/api/routes/v1/dto/filter.rs | 2 +- src/api/routes/v1/dto/recommendations.rs | 13 +- src/api/routes/v1/dto/series.rs | 132 +++++++++++- src/api/routes/v1/handlers/bulk_metadata.rs | 29 ++- src/api/routes/v1/handlers/plugin_actions.rs | 38 +++- src/api/routes/v1/handlers/recommendations.rs | 17 +- src/api/routes/v1/handlers/series.rs | 72 +++++-- src/services/filter.rs | 24 ++- src/services/metadata/apply.rs | 67 +++--- .../metadata/preprocessing/context.rs | 32 ++- src/services/plugin/library.rs | 5 +- src/services/plugin/recommendations.rs | 25 ++- src/services/series_export_collector.rs | 30 ++- src/services/series_export_writer.rs | 1 + src/tasks/handlers/user_plugin_sync/push.rs | 38 ++-- src/tasks/handlers/user_plugin_sync/tests.rs | 14 +- tests/api/metadata_locks.rs | 20 ++ tests/api/series.rs | 18 +- tests/services/metadata_apply.rs | 168 +++++++++++++++ web/openapi.json | 200 ++++++++++++++++-- web/src/types/api.generated.ts | 177 +++++++++++++++- 24 files changed, 1202 insertions(+), 146 deletions(-) diff --git a/docs/api/openapi.json b/docs/api/openapi.json index 20a86c3f..fcf7bf68 100644 --- a/docs/api/openapi.json +++ b/docs/api/openapi.json @@ -20403,7 +20403,23 @@ "null" ], "format": "int32", - "description": "Expected total book count (for ongoing series)" + "description": "Expected total book count (for ongoing series).\n\nDEPRECATED: kept for one phase of backward-compat with API clients\npinned to the legacy field. Sets `total_volume_count` under the hood.\nRemoved in Phase 9 of the metadata-count-split plan." + }, + "totalChapterCount": { + "type": [ + "number", + "null" + ], + "format": "float", + "description": "Expected total chapter count (for chapter-organized series). May be fractional." + }, + "totalVolumeCount": { + "type": [ + "integer", + "null" + ], + "format": "int32", + "description": "Expected total volume count (for volume-organized series)." }, "year": { "type": [ @@ -23308,7 +23324,25 @@ "null" ], "format": "int32", - "description": "Expected total book count (for ongoing series)", + "description": "Expected total book count (for ongoing series).\n\nDEPRECATED: kept for one phase of backward-compat. Mirrors\n`totalVolumeCount`. Removed in Phase 9.", + "example": 4 + }, + "totalChapterCount": { + "type": [ + "number", + "null" + ], + "format": "float", + "description": "Expected total chapter count (for chapter-organized series). May be fractional.", + "example": 109.5 + }, + "totalVolumeCount": { + "type": [ + "integer", + "null" + ], + "format": "int32", + "description": "Expected total volume count (for volume-organized series).", "example": 4 }, "updatedAt": { @@ -26310,12 +26344,38 @@ "null" ], "format": "int32", - "description": "Expected total book count", + "description": "Expected total book count.\n\nDEPRECATED: kept for one phase of backward-compat. Mirrors\n`totalVolumeCount`. Removed in Phase 9 of the metadata-count-split plan.", "example": 110 }, "totalBookCountLock": { "type": "boolean", - "description": "Whether total_book_count is locked" + "description": "Whether total_book_count is locked.\n\nDEPRECATED: kept for one phase of backward-compat. Mirrors\n`totalVolumeCountLock`. Removed in Phase 9." + }, + "totalChapterCount": { + "type": [ + "number", + "null" + ], + "format": "float", + "description": "Expected total chapter count (may be fractional)", + "example": 1100.5 + }, + "totalChapterCountLock": { + "type": "boolean", + "description": "Whether total_chapter_count is locked" + }, + "totalVolumeCount": { + "type": [ + "integer", + "null" + ], + "format": "int32", + "description": "Expected total volume count", + "example": 110 + }, + "totalVolumeCountLock": { + "type": "boolean", + "description": "Whether total_volume_count is locked" }, "year": { "type": [ @@ -26378,6 +26438,8 @@ "readingDirection", "year", "totalBookCount", + "totalVolumeCount", + "totalChapterCount", "genres", "tags", "customMetadata", @@ -26463,7 +26525,17 @@ }, "totalBookCount": { "type": "boolean", - "description": "Whether the total_book_count field is locked", + "description": "Whether the total_book_count field is locked.\n\nDEPRECATED: kept for one phase of backward-compat. Mirrors\n`totalVolumeCountLock`. Removed in Phase 9.", + "example": false + }, + "totalChapterCount": { + "type": "boolean", + "description": "Whether the total_chapter_count field is locked", + "example": false + }, + "totalVolumeCount": { + "type": "boolean", + "description": "Whether the total_volume_count field is locked", "example": false }, "year": { @@ -28716,7 +28788,25 @@ "null" ], "format": "int32", - "description": "Expected total book count (for ongoing series)", + "description": "Expected total book count (for ongoing series).\n\nDEPRECATED: kept for one phase of backward-compat. Sets\n`totalVolumeCount` under the hood. Removed in Phase 9 of the\nmetadata-count-split plan.", + "example": 4 + }, + "totalChapterCount": { + "type": [ + "number", + "null" + ], + "format": "float", + "description": "Expected total chapter count (for chapter-organized series). May be fractional.", + "example": 109.5 + }, + "totalVolumeCount": { + "type": [ + "integer", + "null" + ], + "format": "int32", + "description": "Expected total volume count (for volume-organized series)", "example": 4 }, "year": { @@ -30665,7 +30755,23 @@ "null" ], "format": "int32", - "description": "Total expected number of books/volumes in the series" + "description": "Total expected number of books/volumes in the series.\n\nDEPRECATED: kept for one phase of backward-compat. Mirrors\n`totalVolumeCount`. Removed in Phase 9 of metadata-count-split." + }, + "totalChapterCount": { + "type": [ + "number", + "null" + ], + "format": "float", + "description": "Total expected number of chapters in the series. May be fractional." + }, + "totalVolumeCount": { + "type": [ + "integer", + "null" + ], + "format": "int32", + "description": "Total expected number of volumes in the series." } } }, @@ -31244,7 +31350,25 @@ "null" ], "format": "int32", - "description": "Expected total book count (for ongoing series)", + "description": "Expected total book count (for ongoing series).\n\nDEPRECATED: kept for one phase of backward-compat. Mirrors\n`totalVolumeCount`. Use `totalVolumeCount` and/or `totalChapterCount`\ngoing forward; this field is removed in Phase 9 of the\nmetadata-count-split plan.", + "example": 4 + }, + "totalChapterCount": { + "type": [ + "number", + "null" + ], + "format": "float", + "description": "Expected total chapter count (for chapter-organized series). May be fractional.", + "example": 109.5 + }, + "totalVolumeCount": { + "type": [ + "integer", + "null" + ], + "format": "int32", + "description": "Expected total volume count (for volume-organized series).", "example": 4 }, "year": { @@ -31913,7 +32037,7 @@ }, { "type": "object", - "description": "Filter by series completion status (complete/incomplete based on book_count vs total_book_count)", + "description": "Filter by series completion status (complete/incomplete based on book_count vs total_volume_count)", "required": [ "completion" ], @@ -32519,7 +32643,25 @@ "null" ], "format": "int32", - "description": "Expected total book count (for ongoing series)", + "description": "Expected total book count (for ongoing series).\n\nDEPRECATED: kept for one phase of backward-compat. Mirrors\n`totalVolumeCount`. Removed in Phase 9.", + "example": 4 + }, + "totalChapterCount": { + "type": [ + "number", + "null" + ], + "format": "float", + "description": "Expected total chapter count (for chapter-organized series). May be fractional.", + "example": 109.5 + }, + "totalVolumeCount": { + "type": [ + "integer", + "null" + ], + "format": "int32", + "description": "Expected total volume count (for volume-organized series).", "example": 4 }, "updatedAt": { @@ -32688,7 +32830,25 @@ "null" ], "format": "int32", - "description": "Expected total book count (for ongoing series)", + "description": "Expected total book count (for ongoing series).\n\nDEPRECATED: kept for one phase of backward-compat. Mirrors\n`totalVolumeCount`. Removed in Phase 9 of the metadata-count-split plan.", + "example": 4 + }, + "totalChapterCount": { + "type": [ + "number", + "null" + ], + "format": "float", + "description": "Expected total chapter count (for chapter-organized series). May be fractional.", + "example": 109.5 + }, + "totalVolumeCount": { + "type": [ + "integer", + "null" + ], + "format": "int32", + "description": "Expected total volume count (for volume-organized series).", "example": 4 }, "updatedAt": { @@ -35498,7 +35658,23 @@ "boolean", "null" ], - "description": "Whether to lock the total_book_count field", + "description": "Whether to lock the total_book_count field.\n\nDEPRECATED: kept for one phase of backward-compat. Sets\n`totalVolumeCountLock` under the hood. Removed in Phase 9.", + "example": false + }, + "totalChapterCount": { + "type": [ + "boolean", + "null" + ], + "description": "Whether to lock the total_chapter_count field", + "example": false + }, + "totalVolumeCount": { + "type": [ + "boolean", + "null" + ], + "description": "Whether to lock the total_volume_count field", "example": false }, "year": { diff --git a/src/api/routes/komga/handlers/series.rs b/src/api/routes/komga/handlers/series.rs index 578d1d4b..d29a7597 100644 --- a/src/api/routes/komga/handlers/series.rs +++ b/src/api/routes/komga/handlers/series.rs @@ -829,8 +829,12 @@ async fn build_series_dto( genres_lock: m.genres_lock, tags: tag_names, tags_lock: m.tags_lock, - total_book_count: m.total_book_count, - total_book_count_lock: m.total_book_count_lock, + // Komga's `totalBookCount` is volume-shaped semantically, so we + // map our `total_volume_count` (and its lock) to it. If/when + // Komga adds a chapter-count field upstream, surface + // `total_chapter_count` there too. + total_book_count: m.total_volume_count, + total_book_count_lock: m.total_volume_count_lock, sharing_labels: Vec::new(), sharing_labels_lock: false, links, @@ -886,7 +890,7 @@ async fn build_series_dto( deleted: false, oneshot: metadata .as_ref() - .and_then(|m| m.total_book_count) + .and_then(|m| m.total_volume_count) .map(|count| count == 1) .unwrap_or(book_count == 1), }) diff --git a/src/api/routes/v1/dto/bulk_metadata.rs b/src/api/routes/v1/dto/bulk_metadata.rs index f2941a31..1831206f 100644 --- a/src/api/routes/v1/dto/bulk_metadata.rs +++ b/src/api/routes/v1/dto/bulk_metadata.rs @@ -81,11 +81,25 @@ pub struct BulkPatchSeriesMetadataRequest { #[schema(value_type = Option, nullable = true)] pub year: super::patch::PatchValue, - /// Expected total book count (for ongoing series) + /// Expected total book count (for ongoing series). + /// + /// DEPRECATED: kept for one phase of backward-compat with API clients + /// pinned to the legacy field. Sets `total_volume_count` under the hood. + /// Removed in Phase 9 of the metadata-count-split plan. #[serde(default)] #[schema(value_type = Option, nullable = true)] pub total_book_count: super::patch::PatchValue, + /// Expected total volume count (for volume-organized series). + #[serde(default)] + #[schema(value_type = Option, nullable = true)] + pub total_volume_count: super::patch::PatchValue, + + /// Expected total chapter count (for chapter-organized series). May be fractional. + #[serde(default)] + #[schema(value_type = Option, nullable = true)] + pub total_chapter_count: super::patch::PatchValue, + /// Custom JSON metadata (uses RFC 7386 JSON Merge Patch semantics) #[serde(default)] #[schema(value_type = Option, nullable = true)] diff --git a/src/api/routes/v1/dto/filter.rs b/src/api/routes/v1/dto/filter.rs index 311fab24..83a0c145 100644 --- a/src/api/routes/v1/dto/filter.rs +++ b/src/api/routes/v1/dto/filter.rs @@ -95,7 +95,7 @@ pub enum SeriesCondition { #[serde(rename = "sharingTag")] sharing_tag: FieldOperator, }, - /// Filter by series completion status (complete/incomplete based on book_count vs total_book_count) + /// Filter by series completion status (complete/incomplete based on book_count vs total_volume_count) Completion { completion: BoolOperator }, /// Filter by whether the series has an external source ID linked HasExternalSourceId { diff --git a/src/api/routes/v1/dto/recommendations.rs b/src/api/routes/v1/dto/recommendations.rs index c2842fc5..32f98791 100644 --- a/src/api/routes/v1/dto/recommendations.rs +++ b/src/api/routes/v1/dto/recommendations.rs @@ -69,9 +69,18 @@ pub struct RecommendationDto { /// Year the series started #[serde(skip_serializing_if = "Option::is_none")] pub start_year: Option, - /// Total expected number of books/volumes in the series + /// Total expected number of books/volumes in the series. + /// + /// DEPRECATED: kept for one phase of backward-compat. Mirrors + /// `totalVolumeCount`. Removed in Phase 9 of metadata-count-split. #[serde(skip_serializing_if = "Option::is_none")] pub total_book_count: Option, + /// Total expected number of volumes in the series. + #[serde(skip_serializing_if = "Option::is_none")] + pub total_volume_count: Option, + /// Total expected number of chapters in the series. May be fractional. + #[serde(skip_serializing_if = "Option::is_none")] + pub total_chapter_count: Option, /// Average user rating on the source service (0-100 scale) #[serde(skip_serializing_if = "Option::is_none")] pub rating: Option, @@ -156,6 +165,8 @@ mod tests { country_of_origin: None, start_year: None, total_book_count: None, + total_volume_count: None, + total_chapter_count: None, rating: None, popularity: None, }; diff --git a/src/api/routes/v1/dto/series.rs b/src/api/routes/v1/dto/series.rs index eb7bba8c..f4e8d045 100644 --- a/src/api/routes/v1/dto/series.rs +++ b/src/api/routes/v1/dto/series.rs @@ -314,10 +314,23 @@ pub struct ReplaceSeriesMetadataRequest { #[schema(example = 1987)] pub year: Option, - /// Expected total book count (for ongoing series) + /// Expected total book count (for ongoing series). + /// + /// DEPRECATED: kept for one phase of backward-compat. Mirrors + /// `totalVolumeCount`. Use `totalVolumeCount` and/or `totalChapterCount` + /// going forward; this field is removed in Phase 9 of the + /// metadata-count-split plan. #[schema(example = 4)] pub total_book_count: Option, + /// Expected total volume count (for volume-organized series). + #[schema(example = 4)] + pub total_volume_count: Option, + + /// Expected total chapter count (for chapter-organized series). May be fractional. + #[schema(example = 109.5)] + pub total_chapter_count: Option, + /// Custom JSON metadata for extensions #[schema(value_type = Option, example = json!({"myField": "value", "nested": {"key": "data"}}))] pub custom_metadata: Option, @@ -383,11 +396,25 @@ pub struct PatchSeriesMetadataRequest { #[schema(value_type = Option, example = 1987, nullable = true)] pub year: super::patch::PatchValue, - /// Expected total book count (for ongoing series) + /// Expected total book count (for ongoing series). + /// + /// DEPRECATED: kept for one phase of backward-compat. Sets + /// `totalVolumeCount` under the hood. Removed in Phase 9 of the + /// metadata-count-split plan. #[serde(default)] #[schema(value_type = Option, example = 4, nullable = true)] pub total_book_count: super::patch::PatchValue, + /// Expected total volume count (for volume-organized series) + #[serde(default)] + #[schema(value_type = Option, example = 4, nullable = true)] + pub total_volume_count: super::patch::PatchValue, + + /// Expected total chapter count (for chapter-organized series). May be fractional. + #[serde(default)] + #[schema(value_type = Option, example = 109.5, nullable = true)] + pub total_chapter_count: super::patch::PatchValue, + /// Custom JSON metadata for extensions #[serde(default)] #[schema(value_type = Option, example = json!({"myField": "value"}), nullable = true)] @@ -447,10 +474,21 @@ pub struct SeriesMetadataResponse { #[schema(example = 1987)] pub year: Option, - /// Expected total book count (for ongoing series) + /// Expected total book count (for ongoing series). + /// + /// DEPRECATED: kept for one phase of backward-compat. Mirrors + /// `totalVolumeCount`. Removed in Phase 9 of the metadata-count-split plan. #[schema(example = 4)] pub total_book_count: Option, + /// Expected total volume count (for volume-organized series). + #[schema(example = 4)] + pub total_volume_count: Option, + + /// Expected total chapter count (for chapter-organized series). May be fractional. + #[schema(example = 109.5)] + pub total_chapter_count: Option, + /// Custom JSON metadata for extensions #[schema(value_type = Option, example = json!({"myField": "value"}))] pub custom_metadata: Option, @@ -974,10 +1012,21 @@ pub struct MetadataLocks { #[schema(example = false)] pub year: bool, - /// Whether the total_book_count field is locked + /// Whether the total_book_count field is locked. + /// + /// DEPRECATED: kept for one phase of backward-compat. Mirrors + /// `totalVolumeCountLock`. Removed in Phase 9. #[schema(example = false)] pub total_book_count: bool, + /// Whether the total_volume_count field is locked + #[schema(example = false)] + pub total_volume_count: bool, + + /// Whether the total_chapter_count field is locked + #[schema(example = false)] + pub total_chapter_count: bool, + /// Whether the genres are locked #[schema(example = false)] pub genres: bool, @@ -1053,10 +1102,21 @@ pub struct FullSeriesMetadataResponse { #[schema(example = 1987)] pub year: Option, - /// Expected total book count (for ongoing series) + /// Expected total book count (for ongoing series). + /// + /// DEPRECATED: kept for one phase of backward-compat. Mirrors + /// `totalVolumeCount`. Removed in Phase 9. #[schema(example = 4)] pub total_book_count: Option, + /// Expected total volume count (for volume-organized series). + #[schema(example = 4)] + pub total_volume_count: Option, + + /// Expected total chapter count (for chapter-organized series). May be fractional. + #[schema(example = 109.5)] + pub total_chapter_count: Option, + /// Custom JSON metadata #[schema(value_type = Option, example = json!({"myField": "value"}))] pub custom_metadata: Option, @@ -1146,11 +1206,24 @@ pub struct SeriesFullMetadata { #[schema(example = 1987)] pub year: Option, - /// Expected total book count (for ongoing series) + /// Expected total book count (for ongoing series). + /// + /// DEPRECATED: kept for one phase of backward-compat. Mirrors + /// `totalVolumeCount`. Removed in Phase 9. #[serde(skip_serializing_if = "Option::is_none")] #[schema(example = 4)] pub total_book_count: Option, + /// Expected total volume count (for volume-organized series). + #[serde(skip_serializing_if = "Option::is_none")] + #[schema(example = 4)] + pub total_volume_count: Option, + + /// Expected total chapter count (for chapter-organized series). May be fractional. + #[serde(skip_serializing_if = "Option::is_none")] + #[schema(example = 109.5)] + pub total_chapter_count: Option, + /// Custom JSON metadata #[serde(skip_serializing_if = "Option::is_none")] #[schema(value_type = Option, example = json!({"myField": "value"}))] @@ -1296,11 +1369,24 @@ pub struct UpdateMetadataLocksRequest { #[schema(example = false)] pub year: Option, - /// Whether to lock the total_book_count field + /// Whether to lock the total_book_count field. + /// + /// DEPRECATED: kept for one phase of backward-compat. Sets + /// `totalVolumeCountLock` under the hood. Removed in Phase 9. #[serde(skip_serializing_if = "Option::is_none")] #[schema(example = false)] pub total_book_count: Option, + /// Whether to lock the total_volume_count field + #[serde(skip_serializing_if = "Option::is_none")] + #[schema(example = false)] + pub total_volume_count: Option, + + /// Whether to lock the total_chapter_count field + #[serde(skip_serializing_if = "Option::is_none")] + #[schema(example = false)] + pub total_chapter_count: Option, + /// Whether to lock the genres #[serde(skip_serializing_if = "Option::is_none")] #[schema(example = false)] @@ -1673,11 +1759,24 @@ pub struct MetadataContextDto { #[schema(example = 1997)] pub year: Option, - /// Expected total book count + /// Expected total book count. + /// + /// DEPRECATED: kept for one phase of backward-compat. Mirrors + /// `totalVolumeCount`. Removed in Phase 9 of the metadata-count-split plan. #[serde(skip_serializing_if = "Option::is_none")] #[schema(example = 110)] pub total_book_count: Option, + /// Expected total volume count + #[serde(skip_serializing_if = "Option::is_none")] + #[schema(example = 110)] + pub total_volume_count: Option, + + /// Expected total chapter count (may be fractional) + #[serde(skip_serializing_if = "Option::is_none")] + #[schema(example = 1100.5)] + pub total_chapter_count: Option, + /// Genre names #[serde(default)] #[schema(example = json!(["Action", "Adventure", "Comedy"]))] @@ -1745,10 +1844,21 @@ pub struct MetadataContextDto { #[serde(default)] pub year_lock: bool, - /// Whether total_book_count is locked + /// Whether total_book_count is locked. + /// + /// DEPRECATED: kept for one phase of backward-compat. Mirrors + /// `totalVolumeCountLock`. Removed in Phase 9. #[serde(default)] pub total_book_count_lock: bool, + /// Whether total_volume_count is locked + #[serde(default)] + pub total_volume_count_lock: bool, + + /// Whether total_chapter_count is locked + #[serde(default)] + pub total_chapter_count_lock: bool, + /// Whether genres are locked #[serde(default)] pub genres_lock: bool, @@ -1862,6 +1972,8 @@ impl From for reading_direction: ctx.metadata.reading_direction, year: ctx.metadata.year, total_book_count: ctx.metadata.total_book_count, + total_volume_count: ctx.metadata.total_volume_count, + total_chapter_count: ctx.metadata.total_chapter_count, genres: ctx.metadata.genres, tags: ctx.metadata.tags, alternate_titles: ctx @@ -1914,6 +2026,8 @@ impl From for reading_direction_lock: ctx.metadata.reading_direction_lock, year_lock: ctx.metadata.year_lock, total_book_count_lock: ctx.metadata.total_book_count_lock, + total_volume_count_lock: ctx.metadata.total_volume_count_lock, + total_chapter_count_lock: ctx.metadata.total_chapter_count_lock, genres_lock: ctx.metadata.genres_lock, tags_lock: ctx.metadata.tags_lock, custom_metadata_lock: ctx.metadata.custom_metadata_lock, diff --git a/src/api/routes/v1/handlers/bulk_metadata.rs b/src/api/routes/v1/handlers/bulk_metadata.rs index 60e22ab0..1f9141a5 100644 --- a/src/api/routes/v1/handlers/bulk_metadata.rs +++ b/src/api/routes/v1/handlers/bulk_metadata.rs @@ -84,7 +84,15 @@ pub async fn bulk_patch_series_metadata( let language_opt = request.language.into_nested_option(); let reading_direction_opt = request.reading_direction.into_nested_option(); let year_opt = request.year.into_nested_option(); - let total_book_count_opt = request.total_book_count.into_nested_option(); + // Legacy `total_book_count` patches route to `total_volume_count` (the + // canonical field after metadata-count-split). If both are sent, the new + // field wins. Removed alongside the legacy field in Phase 9. + let legacy_total_book_count_opt = request.total_book_count.into_nested_option(); + let total_volume_count_opt = request + .total_volume_count + .into_nested_option() + .or(legacy_total_book_count_opt); + let total_chapter_count_opt = request.total_chapter_count.into_nested_option(); let custom_metadata_opt = request.custom_metadata.into_nested_option(); let authors_opt = request.authors.into_nested_option(); @@ -134,8 +142,12 @@ pub async fn bulk_patch_series_metadata( active.year = Set(*opt); has_changes = true; } - if let Some(ref opt) = total_book_count_opt { - active.total_book_count = Set(*opt); + if let Some(ref opt) = total_volume_count_opt { + active.total_volume_count = Set(*opt); + has_changes = true; + } + if let Some(ref opt) = total_chapter_count_opt { + active.total_chapter_count = Set(*opt); has_changes = true; } if let Some(ref opt) = custom_metadata_opt { @@ -905,8 +917,15 @@ pub async fn bulk_update_series_locks( active.year_lock = Set(v); has_changes = true; } - if let Some(v) = locks.total_book_count { - active.total_book_count_lock = Set(v); + // Legacy `total_book_count` lock routes to `total_volume_count_lock`. + // If both are sent, the new field wins. Removed in Phase 9. + let resolved_volume_lock = locks.total_volume_count.or(locks.total_book_count); + if let Some(v) = resolved_volume_lock { + active.total_volume_count_lock = Set(v); + has_changes = true; + } + if let Some(v) = locks.total_chapter_count { + active.total_chapter_count_lock = Set(v); has_changes = true; } if let Some(v) = locks.genres { diff --git a/src/api/routes/v1/handlers/plugin_actions.rs b/src/api/routes/v1/handlers/plugin_actions.rs index 223017ee..a3055a37 100644 --- a/src/api/routes/v1/handlers/plugin_actions.rs +++ b/src/api/routes/v1/handlers/plugin_actions.rs @@ -1079,20 +1079,46 @@ pub async fn preview_series_metadata( &mut not_provided, )); - // Total Book Count + // Total Volume Count. + // + // Backward-compat: a plugin that still sends only the legacy + // `total_book_count` is treated as if it sent `total_volume_count`. The + // legacy compatibility shim is removed in Phase 9. + let proposed_volume_count = plugin_metadata + .total_volume_count + .or(plugin_metadata.total_book_count); fields.push(build_field_preview( - "totalBookCount", + "totalVolumeCount", current_metadata .as_ref() - .and_then(|m| m.total_book_count.map(|v| serde_json::json!(v))), + .and_then(|m| m.total_volume_count.map(|v| serde_json::json!(v))), + proposed_volume_count.map(|v| serde_json::json!(v)), + current_metadata + .as_ref() + .map(|m| m.total_volume_count_lock) + .unwrap_or(false), + has_permission(PluginPermission::MetadataWriteTotalVolumeCount), + &mut will_apply, + &mut locked, + &mut no_permission, + &mut unchanged, + &mut not_provided, + )); + + // Total Chapter Count. + fields.push(build_field_preview( + "totalChapterCount", + current_metadata + .as_ref() + .and_then(|m| m.total_chapter_count.map(|v| serde_json::json!(v))), plugin_metadata - .total_book_count + .total_chapter_count .map(|v| serde_json::json!(v)), current_metadata .as_ref() - .map(|m| m.total_book_count_lock) + .map(|m| m.total_chapter_count_lock) .unwrap_or(false), - has_permission(PluginPermission::MetadataWriteTotalBookCount), + has_permission(PluginPermission::MetadataWriteTotalChapterCount), &mut will_apply, &mut locked, &mut no_permission, diff --git a/src/api/routes/v1/handlers/recommendations.rs b/src/api/routes/v1/handlers/recommendations.rs index f61c5f0a..2a4846aa 100644 --- a/src/api/routes/v1/handlers/recommendations.rs +++ b/src/api/routes/v1/handlers/recommendations.rs @@ -384,7 +384,12 @@ fn to_recommendation_dto( format: r.format, country_of_origin: r.country_of_origin, start_year: r.start_year, - total_book_count: r.total_book_count, + // Legacy `totalBookCount` mirrors whichever volume count the plugin + // sends; if the plugin populated only the new field, we still surface + // it under both keys for one phase. Removed in Phase 9. + total_book_count: r.total_volume_count.or(r.total_book_count), + total_volume_count: r.total_volume_count.or(r.total_book_count), + total_chapter_count: r.total_chapter_count, rating: r.rating, popularity: r.popularity, } @@ -554,6 +559,8 @@ mod tests { country_of_origin: Some("JP".to_string()), start_year: Some(2005), total_book_count: Some(27), + total_volume_count: Some(27), + total_chapter_count: None, rating: Some(90), popularity: Some(50000), }; @@ -608,6 +615,8 @@ mod tests { country_of_origin: None, start_year: None, total_book_count: None, + total_volume_count: None, + total_chapter_count: None, rating: None, popularity: None, }; @@ -656,6 +665,8 @@ mod tests { country_of_origin: Some("JP".to_string()), start_year: Some(2005), total_book_count: Some(30), + total_volume_count: Some(30), + total_chapter_count: None, rating: Some(88), popularity: Some(75000), }), @@ -677,6 +688,8 @@ mod tests { country_of_origin: None, start_year: None, total_book_count: None, + total_volume_count: None, + total_chapter_count: None, rating: None, popularity: None, }), @@ -771,6 +784,8 @@ mod tests { country_of_origin: None, start_year: None, total_book_count: None, + total_volume_count: None, + total_chapter_count: None, rating: None, popularity: None, }], diff --git a/src/api/routes/v1/handlers/series.rs b/src/api/routes/v1/handlers/series.rs index 970019d8..1cc625a8 100644 --- a/src/api/routes/v1/handlers/series.rs +++ b/src/api/routes/v1/handlers/series.rs @@ -410,7 +410,9 @@ async fn series_to_full_dtos_batched( language: metadata.language.clone(), reading_direction: metadata.reading_direction.clone(), year: metadata.year, - total_book_count: metadata.total_book_count, + total_book_count: metadata.total_volume_count, + total_volume_count: metadata.total_volume_count, + total_chapter_count: metadata.total_chapter_count, custom_metadata: parse_custom_metadata(metadata.custom_metadata.as_deref()), authors: metadata .authors_json @@ -427,7 +429,9 @@ async fn series_to_full_dtos_batched( language: metadata.language_lock, reading_direction: metadata.reading_direction_lock, year: metadata.year_lock, - total_book_count: metadata.total_book_count_lock, + total_book_count: metadata.total_volume_count_lock, + total_volume_count: metadata.total_volume_count_lock, + total_chapter_count: metadata.total_chapter_count_lock, genres: metadata.genres_lock, tags: metadata.tags_lock, custom_metadata: metadata.custom_metadata_lock, @@ -2501,7 +2505,11 @@ pub async fn replace_series_metadata( active.language = Set(request.language.clone()); active.reading_direction = Set(request.reading_direction.clone()); active.year = Set(request.year); - active.total_book_count = Set(request.total_book_count); + // Legacy `total_book_count` requests route to `total_volume_count`; if both + // are present, the new field wins. Removed in Phase 9 of metadata-count-split. + let resolved_volume_count = request.total_volume_count.or(request.total_book_count); + active.total_volume_count = Set(resolved_volume_count); + active.total_chapter_count = Set(request.total_chapter_count); // Validate and convert custom_metadata from JSON Value to String if let Some(ref cm) = request.custom_metadata { @@ -2556,7 +2564,9 @@ pub async fn replace_series_metadata( language: updated_metadata.language, reading_direction: updated_metadata.reading_direction, year: updated_metadata.year, - total_book_count: updated_metadata.total_book_count, + total_book_count: updated_metadata.total_volume_count, + total_volume_count: updated_metadata.total_volume_count, + total_chapter_count: updated_metadata.total_chapter_count, custom_metadata: parse_custom_metadata(updated_metadata.custom_metadata.as_deref()), authors: updated_metadata .authors_json @@ -2700,7 +2710,9 @@ pub async fn reset_series_metadata( language: metadata.language, reading_direction: metadata.reading_direction, year: metadata.year, - total_book_count: metadata.total_book_count, + total_book_count: metadata.total_volume_count, + total_volume_count: metadata.total_volume_count, + total_chapter_count: metadata.total_chapter_count, custom_metadata: parse_custom_metadata(metadata.custom_metadata.as_deref()), authors: metadata .authors_json @@ -2717,7 +2729,9 @@ pub async fn reset_series_metadata( language: metadata.language_lock, reading_direction: metadata.reading_direction_lock, year: metadata.year_lock, - total_book_count: metadata.total_book_count_lock, + total_book_count: metadata.total_volume_count_lock, + total_volume_count: metadata.total_volume_count_lock, + total_chapter_count: metadata.total_chapter_count_lock, genres: metadata.genres_lock, tags: metadata.tags_lock, custom_metadata: metadata.custom_metadata_lock, @@ -2825,8 +2839,19 @@ pub async fn patch_series_metadata( metadata_active.year = Set(opt); has_changes = true; } - if let Some(opt) = request.total_book_count.into_nested_option() { - metadata_active.total_book_count = Set(opt); + // Legacy `total_book_count` patches route to `total_volume_count`; if both + // are sent, the new field wins. Removed in Phase 9 of metadata-count-split. + let legacy_volume_count_patch = request.total_book_count.into_nested_option(); + let volume_count_patch = request + .total_volume_count + .into_nested_option() + .or(legacy_volume_count_patch); + if let Some(opt) = volume_count_patch { + metadata_active.total_volume_count = Set(opt); + has_changes = true; + } + if let Some(opt) = request.total_chapter_count.into_nested_option() { + metadata_active.total_chapter_count = Set(opt); has_changes = true; } if let Some(opt) = request.custom_metadata.into_nested_option() { @@ -2887,7 +2912,9 @@ pub async fn patch_series_metadata( language: updated_metadata.language, reading_direction: updated_metadata.reading_direction, year: updated_metadata.year, - total_book_count: updated_metadata.total_book_count, + total_book_count: updated_metadata.total_volume_count, + total_volume_count: updated_metadata.total_volume_count, + total_chapter_count: updated_metadata.total_chapter_count, custom_metadata: parse_custom_metadata(updated_metadata.custom_metadata.as_deref()), authors: updated_metadata .authors_json @@ -3032,7 +3059,9 @@ pub async fn get_series_metadata( language: metadata.language, reading_direction: metadata.reading_direction, year: metadata.year, - total_book_count: metadata.total_book_count, + total_book_count: metadata.total_volume_count, + total_volume_count: metadata.total_volume_count, + total_chapter_count: metadata.total_chapter_count, custom_metadata: parse_custom_metadata(metadata.custom_metadata.as_deref()), authors: metadata .authors_json @@ -3049,7 +3078,9 @@ pub async fn get_series_metadata( language: metadata.language_lock, reading_direction: metadata.reading_direction_lock, year: metadata.year_lock, - total_book_count: metadata.total_book_count_lock, + total_book_count: metadata.total_volume_count_lock, + total_volume_count: metadata.total_volume_count_lock, + total_chapter_count: metadata.total_chapter_count_lock, genres: metadata.genres_lock, tags: metadata.tags_lock, custom_metadata: metadata.custom_metadata_lock, @@ -3154,8 +3185,15 @@ pub async fn update_metadata_locks( active.year_lock = Set(v); has_changes = true; } - if let Some(v) = request.total_book_count { - active.total_book_count_lock = Set(v); + // Legacy `total_book_count` lock route to `total_volume_count_lock`. If + // both are sent, the new field wins. Removed in Phase 9. + let resolved_volume_lock = request.total_volume_count.or(request.total_book_count); + if let Some(v) = resolved_volume_lock { + active.total_volume_count_lock = Set(v); + has_changes = true; + } + if let Some(v) = request.total_chapter_count { + active.total_chapter_count_lock = Set(v); has_changes = true; } if let Some(v) = request.genres { @@ -3208,7 +3246,9 @@ pub async fn update_metadata_locks( language: updated.language_lock, reading_direction: updated.reading_direction_lock, year: updated.year_lock, - total_book_count: updated.total_book_count_lock, + total_book_count: updated.total_volume_count_lock, + total_volume_count: updated.total_volume_count_lock, + total_chapter_count: updated.total_chapter_count_lock, genres: updated.genres_lock, tags: updated.tags_lock, custom_metadata: updated.custom_metadata_lock, @@ -3266,7 +3306,9 @@ pub async fn get_metadata_locks( language: metadata.language_lock, reading_direction: metadata.reading_direction_lock, year: metadata.year_lock, - total_book_count: metadata.total_book_count_lock, + total_book_count: metadata.total_volume_count_lock, + total_volume_count: metadata.total_volume_count_lock, + total_chapter_count: metadata.total_chapter_count_lock, genres: metadata.genres_lock, tags: metadata.tags_lock, custom_metadata: metadata.custom_metadata_lock, diff --git a/src/services/filter.rs b/src/services/filter.rs index 5a251d3d..559e72b6 100644 --- a/src/services/filter.rs +++ b/src/services/filter.rs @@ -668,14 +668,18 @@ impl FilterService { /// Filter series by completion status /// /// A series is considered "complete" when: - /// - It has a total_book_count set in metadata AND - /// - The actual book_count equals total_book_count + /// - It has a total_volume_count set in metadata AND + /// - The actual book_count equals total_volume_count /// /// A series is considered "incomplete" (missing books) when: - /// - It has a total_book_count set in metadata AND - /// - The actual book_count is less than total_book_count + /// - It has a total_volume_count set in metadata AND + /// - The actual book_count is less than total_volume_count /// - /// Series without total_book_count are excluded from both filters. + /// Series without total_volume_count are excluded from both filters. + /// Note: chapter-organized series (where total_chapter_count is the + /// meaningful axis) are deliberately not handled here; this filter targets + /// volume-organized libraries. A separate chapter-completion filter can be + /// added if/when needed. async fn filter_by_completion( db: &DatabaseConnection, operator: &BoolOperator, @@ -684,12 +688,12 @@ impl FilterService { use crate::db::entities::{books, series_metadata}; use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, QuerySelect}; - // Get all series with total_book_count set + // Get all series with total_volume_count set let series_with_total: Vec<(Uuid, i32)> = series_metadata::Entity::find() - .filter(series_metadata::Column::TotalBookCount.is_not_null()) + .filter(series_metadata::Column::TotalVolumeCount.is_not_null()) .select_only() .column(series_metadata::Column::SeriesId) - .column(series_metadata::Column::TotalBookCount) + .column(series_metadata::Column::TotalVolumeCount) .into_tuple() .all(db) .await?; @@ -733,9 +737,9 @@ impl FilterService { // Determine which series match the completion filter let mut result = HashSet::new(); - for (series_id, total_book_count) in series_with_total { + for (series_id, total_volume_count) in series_with_total { let actual_count = book_count_map.get(&series_id).copied().unwrap_or(0); - let is_complete = actual_count >= total_book_count as i64; + let is_complete = actual_count >= total_volume_count as i64; let matches = match operator { BoolOperator::IsTrue => is_complete, diff --git a/src/services/metadata/apply.rs b/src/services/metadata/apply.rs index fa9367f4..d4617fea 100644 --- a/src/services/metadata/apply.rs +++ b/src/services/metadata/apply.rs @@ -8,7 +8,7 @@ use sea_orm::DatabaseConnection; use sea_orm::prelude::Decimal; use serde::{Deserialize, Serialize}; use std::collections::{HashMap, HashSet}; -use std::sync::Arc; +use std::sync::{Arc, Mutex, OnceLock}; use tracing::warn; use uuid::Uuid; @@ -336,40 +336,26 @@ impl MetadataApplier { } } - // Total Book Count + // Total Volume Count // - // DEPRECATED: kept through Phase 3 of metadata-count-split for backward - // compatibility with plugins that still emit the legacy field. Phase 4 - // stops the write here and adds a fallback that routes the legacy value - // to `totalVolumeCount`. Phase 9 removes this block entirely. - if should_apply_field("totalBookCount") - && let Some(total_book_count) = metadata.total_book_count - { - let is_locked = current_metadata - .map(|m| m.total_book_count_lock) - .unwrap_or(false); - match check_field( - "totalBookCount", - is_locked, - PluginPermission::MetadataWriteTotalBookCount, - ) { - Ok(_) => { - SeriesMetadataRepository::update_total_book_count( - db, - series_id, - Some(total_book_count), - ) - .await - .context("Failed to update total book count")?; - applied_fields.push("totalBookCount".to_string()); + // Backward-compat fallback (Phase 4 of metadata-count-split): if the + // plugin only sent the legacy `total_book_count` field, route its value + // into `total_volume_count` and emit a one-time deprecation warning per + // plugin. The fallback is removed in Phase 9 along with the legacy + // field on the protocol. + let effective_total_volume_count = match metadata.total_volume_count { + Some(v) => Some(v), + None => { + if let Some(legacy) = metadata.total_book_count { + warn_legacy_total_book_count(&plugin.name); + Some(legacy) + } else { + None } - Err(skip) => skipped_fields.push(skip), } - } - - // Total Volume Count + }; if should_apply_field("totalVolumeCount") - && let Some(total_volume_count) = metadata.total_volume_count + && let Some(total_volume_count) = effective_total_volume_count { let is_locked = current_metadata .map(|m| m.total_volume_count_lock) @@ -626,3 +612,22 @@ impl MetadataApplier { }) } } + +/// Emit a one-time-per-plugin deprecation warning when a plugin sends the +/// legacy `total_book_count` field instead of `total_volume_count`. Removed +/// in Phase 9 of the metadata-count-split plan. +fn warn_legacy_total_book_count(plugin_name: &str) { + static WARNED: OnceLock>> = OnceLock::new(); + let warned = WARNED.get_or_init(|| Mutex::new(HashSet::new())); + let mut guard = match warned.lock() { + Ok(g) => g, + Err(poisoned) => poisoned.into_inner(), + }; + if guard.insert(plugin_name.to_string()) { + warn!( + plugin = plugin_name, + "Plugin sent deprecated `total_book_count` field; routing to `total_volume_count`. \ + Update the plugin to emit `total_volume_count` and/or `total_chapter_count` directly." + ); + } +} diff --git a/src/services/metadata/preprocessing/context.rs b/src/services/metadata/preprocessing/context.rs index 8f734af9..8f8d9d0a 100644 --- a/src/services/metadata/preprocessing/context.rs +++ b/src/services/metadata/preprocessing/context.rs @@ -107,7 +107,13 @@ pub struct MetadataContext { pub language: Option, pub reading_direction: Option, pub year: Option, + /// DEPRECATED: kept for one phase of backward-compat. Mirrors + /// `total_volume_count`. Removed in Phase 9 of the metadata-count-split + /// plan; preprocessing rules should reference `totalVolumeCount` or + /// `totalChapterCount` going forward. pub total_book_count: Option, + pub total_volume_count: Option, + pub total_chapter_count: Option, /// Genre names for this series #[serde(default)] @@ -144,7 +150,11 @@ pub struct MetadataContext { pub language_lock: bool, pub reading_direction_lock: bool, pub year_lock: bool, + /// DEPRECATED: kept for one phase of backward-compat alongside + /// `total_book_count`. Removed in Phase 9. pub total_book_count_lock: bool, + pub total_volume_count_lock: bool, + pub total_chapter_count_lock: bool, pub genres_lock: bool, pub tags_lock: bool, pub custom_metadata_lock: bool, @@ -352,6 +362,14 @@ impl SeriesContext { .metadata .total_book_count .map(|v| FieldValue::Number(v as f64)), + "totalVolumeCount" | "total_volume_count" => self + .metadata + .total_volume_count + .map(|v| FieldValue::Number(v as f64)), + "totalChapterCount" | "total_chapter_count" => self + .metadata + .total_chapter_count + .map(|v| FieldValue::Number(v as f64)), // Array fields "genres" => { let arr: Vec = self @@ -429,6 +447,12 @@ impl SeriesContext { "totalBookCountLock" | "total_book_count_lock" => { Some(FieldValue::Bool(self.metadata.total_book_count_lock)) } + "totalVolumeCountLock" | "total_volume_count_lock" => { + Some(FieldValue::Bool(self.metadata.total_volume_count_lock)) + } + "totalChapterCountLock" | "total_chapter_count_lock" => { + Some(FieldValue::Bool(self.metadata.total_chapter_count_lock)) + } "genresLock" | "genres_lock" => Some(FieldValue::Bool(self.metadata.genres_lock)), "tagsLock" | "tags_lock" => Some(FieldValue::Bool(self.metadata.tags_lock)), "customMetadataLock" | "custom_metadata_lock" => { @@ -603,7 +627,9 @@ impl SeriesContextBuilder { language: m.language.clone(), reading_direction: m.reading_direction.clone(), year: m.year, - total_book_count: m.total_book_count, + total_book_count: m.total_volume_count, + total_volume_count: m.total_volume_count, + total_chapter_count: m.total_chapter_count, genres: genres.iter().map(|g| g.name.clone()).collect(), tags: tags.iter().map(|t| t.name.clone()).collect(), alternate_titles: alternate_titles_ctx, @@ -620,7 +646,9 @@ impl SeriesContextBuilder { language_lock: m.language_lock, reading_direction_lock: m.reading_direction_lock, year_lock: m.year_lock, - total_book_count_lock: m.total_book_count_lock, + total_book_count_lock: m.total_volume_count_lock, + total_volume_count_lock: m.total_volume_count_lock, + total_chapter_count_lock: m.total_chapter_count_lock, genres_lock: m.genres_lock, tags_lock: m.tags_lock, custom_metadata_lock: m.custom_metadata_lock, diff --git a/src/services/plugin/library.rs b/src/services/plugin/library.rs index c9205ae4..af32d807 100644 --- a/src/services/plugin/library.rs +++ b/src/services/plugin/library.rs @@ -169,7 +169,10 @@ pub async fn build_user_library( }), genres, tags, - total_book_count: meta.and_then(|m| m.total_book_count), + // Legacy `total_book_count` mirrors `total_volume_count` for one phase + // to keep older plugins compatible. Removed in Phase 9 of the + // metadata-count-split plan. + total_book_count: meta.and_then(|m| m.total_volume_count), total_volume_count: meta.and_then(|m| m.total_volume_count), total_chapter_count: meta.and_then(|m| m.total_chapter_count), external_ids, diff --git a/src/services/plugin/recommendations.rs b/src/services/plugin/recommendations.rs index d8dbd88b..a46e6458 100644 --- a/src/services/plugin/recommendations.rs +++ b/src/services/plugin/recommendations.rs @@ -126,9 +126,20 @@ pub struct Recommendation { /// Year the series started #[serde(default, skip_serializing_if = "Option::is_none")] pub start_year: Option, - /// Total expected number of books/volumes in the series + /// Total expected number of books/volumes in the series. + /// + /// DEPRECATED: kept for one phase of backward-compat with older + /// recommendation plugins. Plugins should populate `total_volume_count` + /// and/or `total_chapter_count` instead. Removed in Phase 9 of the + /// metadata-count-split plan. #[serde(default, skip_serializing_if = "Option::is_none")] pub total_book_count: Option, + /// Total expected number of volumes in the series, when known. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub total_volume_count: Option, + /// Total expected number of chapters in the series, when known. May be fractional. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub total_chapter_count: Option, /// Average user rating on the source service (0-100 scale) #[serde(default, skip_serializing_if = "Option::is_none")] pub rating: Option, @@ -328,6 +339,8 @@ mod tests { country_of_origin: None, start_year: None, total_book_count: Some(27), + total_volume_count: Some(27), + total_chapter_count: None, rating: Some(85), popularity: Some(120000), }], @@ -397,6 +410,8 @@ mod tests { country_of_origin: Some("JP".to_string()), start_year: Some(1994), total_book_count: Some(18), + total_volume_count: Some(18), + total_chapter_count: Some(162.0), rating: Some(92), popularity: Some(85000), }; @@ -417,6 +432,8 @@ mod tests { assert!(json["inLibrary"].as_bool().unwrap()); assert_eq!(json["status"], "ended"); assert_eq!(json["totalBookCount"], 18); + assert_eq!(json["totalVolumeCount"], 18); + assert_eq!(json["totalChapterCount"], 162.0); assert_eq!(json["rating"], 92); assert_eq!(json["popularity"], 85000); } @@ -447,6 +464,8 @@ mod tests { assert!(rec.country_of_origin.is_none()); assert!(rec.start_year.is_none()); assert!(rec.total_book_count.is_none()); + assert!(rec.total_volume_count.is_none()); + assert!(rec.total_chapter_count.is_none()); assert!(rec.rating.is_none()); assert!(rec.popularity.is_none()); } @@ -471,6 +490,8 @@ mod tests { country_of_origin: None, start_year: None, total_book_count: None, + total_volume_count: None, + total_chapter_count: None, rating: None, popularity: None, }; @@ -488,6 +509,8 @@ mod tests { assert!(!obj.contains_key("countryOfOrigin")); assert!(!obj.contains_key("startYear")); assert!(!obj.contains_key("totalBookCount")); + assert!(!obj.contains_key("totalVolumeCount")); + assert!(!obj.contains_key("totalChapterCount")); assert!(!obj.contains_key("rating")); assert!(!obj.contains_key("popularity")); } diff --git a/src/services/series_export_collector.rs b/src/services/series_export_collector.rs index ea14149e..f8cb9ea2 100644 --- a/src/services/series_export_collector.rs +++ b/src/services/series_export_collector.rs @@ -50,6 +50,7 @@ pub enum ExportField { AlternateTitles, // Counts ExpectedBookCount, + ExpectedChapterCount, ActualBookCount, UnreadBookCount, // Progress @@ -82,6 +83,7 @@ impl ExportField { ExportField::Tags, ExportField::AlternateTitles, ExportField::ExpectedBookCount, + ExportField::ExpectedChapterCount, ExportField::ActualBookCount, ExportField::UnreadBookCount, ExportField::Progress, @@ -130,6 +132,7 @@ impl ExportField { ExportField::Tags => "tags", ExportField::AlternateTitles => "alternate_titles", ExportField::ExpectedBookCount => "expected_book_count", + ExportField::ExpectedChapterCount => "expected_chapter_count", ExportField::ActualBookCount => "actual_book_count", ExportField::UnreadBookCount => "unread_book_count", ExportField::Progress => "progress", @@ -160,6 +163,7 @@ impl ExportField { "tags" => Some(ExportField::Tags), "alternate_titles" => Some(ExportField::AlternateTitles), "expected_book_count" => Some(ExportField::ExpectedBookCount), + "expected_chapter_count" => Some(ExportField::ExpectedChapterCount), "actual_book_count" => Some(ExportField::ActualBookCount), "unread_book_count" => Some(ExportField::UnreadBookCount), "progress" => Some(ExportField::Progress), @@ -192,6 +196,7 @@ impl ExportField { ExportField::Tags => "Tags", ExportField::AlternateTitles => "Alternate Titles", ExportField::ExpectedBookCount => "Expected Book Count", + ExportField::ExpectedChapterCount => "Expected Chapter Count", ExportField::ActualBookCount => "Actual Book Count", ExportField::UnreadBookCount => "Unread Book Count", ExportField::Progress => "Progress", @@ -283,6 +288,8 @@ pub struct SeriesExportRow { #[serde(skip_serializing_if = "Option::is_none")] pub expected_book_count: Option, #[serde(skip_serializing_if = "Option::is_none")] + pub expected_chapter_count: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub actual_book_count: Option, #[serde(skip_serializing_if = "Option::is_none")] pub unread_book_count: Option, @@ -319,6 +326,7 @@ impl SeriesExportRow { tags: None, alternate_titles: None, expected_book_count: None, + expected_chapter_count: None, actual_book_count: None, unread_book_count: None, progress: None, @@ -351,6 +359,7 @@ impl SeriesExportRow { tags: None, alternate_titles: None, expected_book_count: None, + expected_chapter_count: None, actual_book_count: None, unread_book_count: None, progress: None, @@ -386,6 +395,10 @@ impl SeriesExportRow { .expected_book_count .map(|c| c.to_string()) .unwrap_or_default(), + ExportField::ExpectedChapterCount => self + .expected_chapter_count + .map(format_chapter_count) + .unwrap_or_default(), ExportField::ActualBookCount => self .actual_book_count .map(|c| c.to_string()) @@ -410,6 +423,15 @@ impl SeriesExportRow { // Helpers for formatting multi-value fields // ============================================================================= +/// Format a chapter count, dropping the trailing `.0` for whole-number values. +fn format_chapter_count(c: f32) -> String { + if c.fract() == 0.0 { + format!("{}", c as i64) + } else { + format!("{c}") + } +} + /// Format authors_json string into "name (role); name (role); ..." format. fn format_authors(authors_json: &Option) -> Option { let json_str = authors_json.as_deref()?; @@ -503,7 +525,8 @@ pub async fn collect_batched( || has(ExportField::Year) || has(ExportField::Language) || has(ExportField::Authors) - || has(ExportField::ExpectedBookCount); + || has(ExportField::ExpectedBookCount) + || has(ExportField::ExpectedChapterCount); let metadata_map = if needs_metadata { SeriesMetadataRepository::get_by_series_ids(db, chunk).await? @@ -617,7 +640,10 @@ pub async fn collect_batched( row.authors = format_authors(&meta.authors_json); } if has(ExportField::ExpectedBookCount) { - row.expected_book_count = meta.total_book_count; + row.expected_book_count = meta.total_volume_count; + } + if has(ExportField::ExpectedChapterCount) { + row.expected_chapter_count = meta.total_chapter_count; } } diff --git a/src/services/series_export_writer.rs b/src/services/series_export_writer.rs index 70135a61..72350d71 100644 --- a/src/services/series_export_writer.rs +++ b/src/services/series_export_writer.rs @@ -413,6 +413,7 @@ mod tests { tags: Some("tag1; tag2".to_string()), alternate_titles: None, expected_book_count: Some(20), + expected_chapter_count: None, actual_book_count: Some(15), unread_book_count: Some(5), progress: Some(66.7), diff --git a/src/tasks/handlers/user_plugin_sync/push.rs b/src/tasks/handlers/user_plugin_sync/push.rs index dd9c2f68..a37c6f80 100644 --- a/src/tasks/handlers/user_plugin_sync/push.rs +++ b/src/tasks/handlers/user_plugin_sync/push.rs @@ -207,19 +207,23 @@ pub(crate) async fn build_push_entries( progress_count, ); - // Use pre-fetched series metadata (for total_book_count) - let total_book_count = metadata_map - .get(&ext_id.series_id) - .and_then(|m| m.total_book_count) + // Use pre-fetched series metadata for completion / progress totals. + let series_meta = metadata_map.get(&ext_id.series_id); + let total_volume_count = series_meta + .and_then(|m| m.total_volume_count) .filter(|&total| total > 0); + let total_chapter_count = series_meta + .and_then(|m| m.total_chapter_count) + .filter(|c| c.is_finite() && *c > 0.0); // Mark as Completed only when: // 1. All local books are read, AND - // 2. The series has a known total_book_count in metadata, AND - // 3. completed_count >= total_book_count + // 2. The series has a known total_volume_count in metadata, AND + // 3. completed_count >= total_volume_count // Otherwise default to Reading — we can't be sure the library is complete. let status = if all_completed { - let is_truly_complete = total_book_count.is_some_and(|total| completed_count >= total); + let is_truly_complete = + total_volume_count.is_some_and(|total| completed_count >= total); if is_truly_complete { SyncReadingStatus::Completed } else { @@ -238,8 +242,8 @@ pub(crate) async fn build_push_entries( chapters: None, volumes: Some(progress_count), pages: None, - total_chapters: None, - total_volumes: total_book_count, + total_chapters: total_chapter_count.map(|c| c as i32), + total_volumes: total_volume_count, }; // Look up rating/notes if sync_ratings is enabled @@ -474,13 +478,17 @@ async fn build_unmatched_entries( completed_count }; - let total_book_count = metadata_map - .get(&series_id) - .and_then(|m| m.total_book_count) + let series_meta = metadata_map.get(&series_id); + let total_volume_count = series_meta + .and_then(|m| m.total_volume_count) .filter(|&total| total > 0); + let total_chapter_count = series_meta + .and_then(|m| m.total_chapter_count) + .filter(|c| c.is_finite() && *c > 0.0); let status = if all_completed { - let is_truly_complete = total_book_count.is_some_and(|total| completed_count >= total); + let is_truly_complete = + total_volume_count.is_some_and(|total| completed_count >= total); if is_truly_complete { SyncReadingStatus::Completed } else { @@ -494,8 +502,8 @@ async fn build_unmatched_entries( chapters: None, volumes: Some(progress_count), pages: None, - total_chapters: None, - total_volumes: total_book_count, + total_chapters: total_chapter_count.map(|c| c as i32), + total_volumes: total_volume_count, }; let (score, notes) = if settings.sync_ratings { diff --git a/src/tasks/handlers/user_plugin_sync/tests.rs b/src/tasks/handlers/user_plugin_sync/tests.rs index 33142d20..e3b6449e 100644 --- a/src/tasks/handlers/user_plugin_sync/tests.rs +++ b/src/tasks/handlers/user_plugin_sync/tests.rs @@ -1632,10 +1632,14 @@ async fn test_build_push_entries_populates_total_volumes() { test_books.push(book); } - // Set total_book_count=3 in metadata (more than the 2 local books) - SeriesMetadataRepository::update_total_book_count(db.sea_orm_connection(), series.id, Some(3)) - .await - .unwrap(); + // Set total_volume_count=3 in metadata (more than the 2 local books) + SeriesMetadataRepository::update_total_volume_count( + db.sea_orm_connection(), + series.id, + Some(3), + ) + .await + .unwrap(); let user = create_test_user(db.sea_orm_connection()).await; @@ -1668,7 +1672,7 @@ async fn test_build_push_entries_populates_total_volumes() { assert_eq!( entries[0].progress.as_ref().unwrap().total_volumes, Some(3), - "totalVolumes should come from series metadata total_book_count" + "totalVolumes should come from series metadata total_volume_count" ); } diff --git a/tests/api/metadata_locks.rs b/tests/api/metadata_locks.rs index 7719d097..6e6c2403 100644 --- a/tests/api/metadata_locks.rs +++ b/tests/api/metadata_locks.rs @@ -295,6 +295,8 @@ async fn test_update_metadata_locks() { tags: None, custom_metadata: None, total_book_count: None, + total_volume_count: None, + total_chapter_count: None, cover: None, authors_json_lock: None, alternate_titles: None, @@ -365,6 +367,8 @@ async fn test_update_metadata_locks_partial() { tags: None, custom_metadata: None, total_book_count: None, + total_volume_count: None, + total_chapter_count: None, cover: None, authors_json_lock: None, alternate_titles: None, @@ -394,6 +398,8 @@ async fn test_update_metadata_locks_partial() { tags: None, custom_metadata: None, total_book_count: None, + total_volume_count: None, + total_chapter_count: None, cover: None, authors_json_lock: None, alternate_titles: None, @@ -447,6 +453,8 @@ async fn test_update_metadata_locks_all_fields() { tags: Some(true), custom_metadata: Some(true), total_book_count: Some(true), + total_volume_count: Some(true), + total_chapter_count: Some(true), cover: Some(true), authors_json_lock: Some(true), alternate_titles: Some(true), @@ -502,6 +510,8 @@ async fn test_update_metadata_locks_not_found() { tags: None, custom_metadata: None, total_book_count: None, + total_volume_count: None, + total_chapter_count: None, cover: None, authors_json_lock: None, alternate_titles: None, @@ -548,6 +558,8 @@ async fn test_update_metadata_locks_empty_request() { tags: None, custom_metadata: None, total_book_count: None, + total_volume_count: None, + total_chapter_count: None, cover: None, authors_json_lock: None, alternate_titles: None, @@ -604,6 +616,8 @@ async fn test_alternate_titles_lock_independent_from_title_lock() { tags: None, custom_metadata: None, total_book_count: None, + total_volume_count: None, + total_chapter_count: None, cover: None, authors_json_lock: None, alternate_titles: None, @@ -659,6 +673,8 @@ async fn test_alternate_titles_lock_without_affecting_title_lock() { tags: None, custom_metadata: None, total_book_count: None, + total_volume_count: None, + total_chapter_count: None, cover: None, authors_json_lock: None, alternate_titles: Some(true), @@ -727,6 +743,8 @@ async fn test_alternate_titles_lock_in_full_metadata_response() { tags: None, custom_metadata: None, total_book_count: None, + total_volume_count: None, + total_chapter_count: None, cover: None, authors_json_lock: None, alternate_titles: Some(true), @@ -797,6 +815,8 @@ async fn test_update_locks_requires_write_permission() { tags: None, custom_metadata: None, total_book_count: None, + total_volume_count: None, + total_chapter_count: None, cover: None, authors_json_lock: None, alternate_titles: None, diff --git a/tests/api/series.rs b/tests/api/series.rs index ca8e65da..e9c366a0 100644 --- a/tests/api/series.rs +++ b/tests/api/series.rs @@ -2002,6 +2002,8 @@ async fn test_replace_series_metadata_success() { year: Some(2020), reading_direction: Some("ltr".to_string()), total_book_count: None, + total_volume_count: None, + total_chapter_count: None, custom_metadata: Some(serde_json::json!({"tag": "value"})), authors: None, }; @@ -2077,6 +2079,8 @@ async fn test_replace_series_metadata_clears_omitted_fields() { year: None, // Should clear year reading_direction: None, total_book_count: None, + total_volume_count: None, + total_chapter_count: None, custom_metadata: None, authors: None, }; @@ -2118,6 +2122,8 @@ async fn test_replace_series_metadata_not_found() { year: None, reading_direction: None, total_book_count: None, + total_volume_count: None, + total_chapter_count: None, custom_metadata: None, authors: None, }; @@ -2164,6 +2170,8 @@ async fn test_replace_series_metadata_without_auth() { year: None, reading_direction: None, total_book_count: None, + total_volume_count: None, + total_chapter_count: None, custom_metadata: None, authors: None, }; @@ -4897,7 +4905,7 @@ async fn test_list_series_filtered_by_completion_complete() { let complete_series = SeriesRepository::create(&db, library.id, "Complete Series", None) .await .unwrap(); - SeriesMetadataRepository::update_total_book_count(&db, complete_series.id, Some(3)) + SeriesMetadataRepository::update_total_volume_count(&db, complete_series.id, Some(3)) .await .unwrap(); for i in 1..=3 { @@ -4915,7 +4923,7 @@ async fn test_list_series_filtered_by_completion_complete() { let incomplete_series = SeriesRepository::create(&db, library.id, "Incomplete Series", None) .await .unwrap(); - SeriesMetadataRepository::update_total_book_count(&db, incomplete_series.id, Some(5)) + SeriesMetadataRepository::update_total_volume_count(&db, incomplete_series.id, Some(5)) .await .unwrap(); for i in 1..=2 { @@ -6000,6 +6008,8 @@ async fn test_replace_series_metadata_with_authors() { year: None, reading_direction: None, total_book_count: None, + total_volume_count: None, + total_chapter_count: None, custom_metadata: None, authors: Some(vec![ serde_json::from_value(serde_json::json!({ @@ -6067,6 +6077,8 @@ async fn test_replace_series_metadata_clears_authors_when_omitted() { year: None, reading_direction: None, total_book_count: None, + total_volume_count: None, + total_chapter_count: None, custom_metadata: None, authors: Some(vec![ serde_json::from_value(serde_json::json!({ @@ -6099,6 +6111,8 @@ async fn test_replace_series_metadata_clears_authors_when_omitted() { year: None, reading_direction: None, total_book_count: None, + total_volume_count: None, + total_chapter_count: None, custom_metadata: None, authors: None, }; diff --git a/tests/services/metadata_apply.rs b/tests/services/metadata_apply.rs index bc07fc9b..adb43dc8 100644 --- a/tests/services/metadata_apply.rs +++ b/tests/services/metadata_apply.rs @@ -747,6 +747,174 @@ async fn test_apply_count_fields_filtered_out_by_allowlist() { assert!(after.total_chapter_count.is_none()); } +// ============================================================================= +// Legacy `total_book_count` Backward-Compat Fallback Tests (Phase 4) +// ============================================================================= + +fn metadata_with_legacy_book_count(total_book_count: Option) -> PluginSeriesMetadata { + PluginSeriesMetadata { + external_id: "legacy-1".to_string(), + external_url: "https://example.com/legacy-1".to_string(), + title: None, + alternate_titles: vec![], + summary: None, + status: None, + year: None, + total_book_count, + total_volume_count: None, + total_chapter_count: None, + language: None, + age_rating: None, + reading_direction: None, + genres: vec![], + tags: vec![], + authors: vec![], + artists: vec![], + publisher: None, + cover_url: None, + banner_url: None, + rating: None, + external_ratings: vec![], + external_links: vec![], + external_ids: vec![], + } +} + +#[tokio::test] +async fn test_apply_legacy_total_book_count_routes_to_total_volume_count() { + let (db, _temp_dir) = setup_test_db().await; + let library = + LibraryRepository::create(&db, "Test Library", "/test/path", ScanningStrategy::Default) + .await + .unwrap(); + let series = SeriesRepository::create(&db, library.id, "Series", None) + .await + .unwrap(); + let current = SeriesMetadataRepository::get_by_series_id(&db, series.id) + .await + .unwrap() + .unwrap(); + + // Plugin only knows the `total_volume_count` permission (the new field) — + // the fallback should still apply because we re-route the legacy value. + let plugin = create_plugin_with_permissions(&["metadata:write:total_volume_count"]); + let result = MetadataApplier::apply( + &db, + series.id, + library.id, + &plugin, + &metadata_with_legacy_book_count(Some(14)), + Some(¤t), + &ApplyOptions::default(), + ) + .await + .unwrap(); + + assert!( + result + .applied_fields + .contains(&"totalVolumeCount".to_string()), + "totalVolumeCount should be applied via the legacy fallback" + ); + let updated = SeriesMetadataRepository::get_by_series_id(&db, series.id) + .await + .unwrap() + .unwrap(); + assert_eq!(updated.total_volume_count, Some(14)); + // Legacy column is no longer written by apply() — Phase 4 freezes it. + assert_eq!( + updated.total_book_count, None, + "legacy total_book_count column should not be written" + ); +} + +#[tokio::test] +async fn test_apply_new_total_volume_count_takes_precedence_over_legacy() { + let (db, _temp_dir) = setup_test_db().await; + let library = + LibraryRepository::create(&db, "Test Library", "/test/path", ScanningStrategy::Default) + .await + .unwrap(); + let series = SeriesRepository::create(&db, library.id, "Series", None) + .await + .unwrap(); + let current = SeriesMetadataRepository::get_by_series_id(&db, series.id) + .await + .unwrap() + .unwrap(); + + // Plugin sends both old and new shape; the new field must win. + let mut metadata = metadata_with_legacy_book_count(Some(99)); + metadata.total_volume_count = Some(14); + + let plugin = create_plugin_with_permissions(&["metadata:write:total_volume_count"]); + MetadataApplier::apply( + &db, + series.id, + library.id, + &plugin, + &metadata, + Some(¤t), + &ApplyOptions::default(), + ) + .await + .unwrap(); + + let updated = SeriesMetadataRepository::get_by_series_id(&db, series.id) + .await + .unwrap() + .unwrap(); + assert_eq!( + updated.total_volume_count, + Some(14), + "new field must take precedence when both are sent" + ); +} + +#[tokio::test] +async fn test_apply_does_not_write_legacy_total_book_count_column() { + let (db, _temp_dir) = setup_test_db().await; + let library = + LibraryRepository::create(&db, "Test Library", "/test/path", ScanningStrategy::Default) + .await + .unwrap(); + let series = SeriesRepository::create(&db, library.id, "Series", None) + .await + .unwrap(); + let current = SeriesMetadataRepository::get_by_series_id(&db, series.id) + .await + .unwrap() + .unwrap(); + + // Even with the deprecated permission granted, apply() must not write the + // legacy `total_book_count` column. Phase 4 stops the write entirely. + let plugin = create_plugin_with_permissions(&[ + "metadata:write:total_book_count", + "metadata:write:total_volume_count", + ]); + MetadataApplier::apply( + &db, + series.id, + library.id, + &plugin, + &metadata_with_legacy_book_count(Some(14)), + Some(¤t), + &ApplyOptions::default(), + ) + .await + .unwrap(); + + let updated = SeriesMetadataRepository::get_by_series_id(&db, series.id) + .await + .unwrap() + .unwrap(); + assert_eq!( + updated.total_book_count, None, + "legacy total_book_count column must remain frozen after Phase 4" + ); + assert_eq!(updated.total_volume_count, Some(14)); +} + #[tokio::test] async fn test_apply_count_fields_skip_when_metadata_value_absent() { let (db, _temp_dir) = setup_test_db().await; diff --git a/web/openapi.json b/web/openapi.json index 20a86c3f..fcf7bf68 100644 --- a/web/openapi.json +++ b/web/openapi.json @@ -20403,7 +20403,23 @@ "null" ], "format": "int32", - "description": "Expected total book count (for ongoing series)" + "description": "Expected total book count (for ongoing series).\n\nDEPRECATED: kept for one phase of backward-compat with API clients\npinned to the legacy field. Sets `total_volume_count` under the hood.\nRemoved in Phase 9 of the metadata-count-split plan." + }, + "totalChapterCount": { + "type": [ + "number", + "null" + ], + "format": "float", + "description": "Expected total chapter count (for chapter-organized series). May be fractional." + }, + "totalVolumeCount": { + "type": [ + "integer", + "null" + ], + "format": "int32", + "description": "Expected total volume count (for volume-organized series)." }, "year": { "type": [ @@ -23308,7 +23324,25 @@ "null" ], "format": "int32", - "description": "Expected total book count (for ongoing series)", + "description": "Expected total book count (for ongoing series).\n\nDEPRECATED: kept for one phase of backward-compat. Mirrors\n`totalVolumeCount`. Removed in Phase 9.", + "example": 4 + }, + "totalChapterCount": { + "type": [ + "number", + "null" + ], + "format": "float", + "description": "Expected total chapter count (for chapter-organized series). May be fractional.", + "example": 109.5 + }, + "totalVolumeCount": { + "type": [ + "integer", + "null" + ], + "format": "int32", + "description": "Expected total volume count (for volume-organized series).", "example": 4 }, "updatedAt": { @@ -26310,12 +26344,38 @@ "null" ], "format": "int32", - "description": "Expected total book count", + "description": "Expected total book count.\n\nDEPRECATED: kept for one phase of backward-compat. Mirrors\n`totalVolumeCount`. Removed in Phase 9 of the metadata-count-split plan.", "example": 110 }, "totalBookCountLock": { "type": "boolean", - "description": "Whether total_book_count is locked" + "description": "Whether total_book_count is locked.\n\nDEPRECATED: kept for one phase of backward-compat. Mirrors\n`totalVolumeCountLock`. Removed in Phase 9." + }, + "totalChapterCount": { + "type": [ + "number", + "null" + ], + "format": "float", + "description": "Expected total chapter count (may be fractional)", + "example": 1100.5 + }, + "totalChapterCountLock": { + "type": "boolean", + "description": "Whether total_chapter_count is locked" + }, + "totalVolumeCount": { + "type": [ + "integer", + "null" + ], + "format": "int32", + "description": "Expected total volume count", + "example": 110 + }, + "totalVolumeCountLock": { + "type": "boolean", + "description": "Whether total_volume_count is locked" }, "year": { "type": [ @@ -26378,6 +26438,8 @@ "readingDirection", "year", "totalBookCount", + "totalVolumeCount", + "totalChapterCount", "genres", "tags", "customMetadata", @@ -26463,7 +26525,17 @@ }, "totalBookCount": { "type": "boolean", - "description": "Whether the total_book_count field is locked", + "description": "Whether the total_book_count field is locked.\n\nDEPRECATED: kept for one phase of backward-compat. Mirrors\n`totalVolumeCountLock`. Removed in Phase 9.", + "example": false + }, + "totalChapterCount": { + "type": "boolean", + "description": "Whether the total_chapter_count field is locked", + "example": false + }, + "totalVolumeCount": { + "type": "boolean", + "description": "Whether the total_volume_count field is locked", "example": false }, "year": { @@ -28716,7 +28788,25 @@ "null" ], "format": "int32", - "description": "Expected total book count (for ongoing series)", + "description": "Expected total book count (for ongoing series).\n\nDEPRECATED: kept for one phase of backward-compat. Sets\n`totalVolumeCount` under the hood. Removed in Phase 9 of the\nmetadata-count-split plan.", + "example": 4 + }, + "totalChapterCount": { + "type": [ + "number", + "null" + ], + "format": "float", + "description": "Expected total chapter count (for chapter-organized series). May be fractional.", + "example": 109.5 + }, + "totalVolumeCount": { + "type": [ + "integer", + "null" + ], + "format": "int32", + "description": "Expected total volume count (for volume-organized series)", "example": 4 }, "year": { @@ -30665,7 +30755,23 @@ "null" ], "format": "int32", - "description": "Total expected number of books/volumes in the series" + "description": "Total expected number of books/volumes in the series.\n\nDEPRECATED: kept for one phase of backward-compat. Mirrors\n`totalVolumeCount`. Removed in Phase 9 of metadata-count-split." + }, + "totalChapterCount": { + "type": [ + "number", + "null" + ], + "format": "float", + "description": "Total expected number of chapters in the series. May be fractional." + }, + "totalVolumeCount": { + "type": [ + "integer", + "null" + ], + "format": "int32", + "description": "Total expected number of volumes in the series." } } }, @@ -31244,7 +31350,25 @@ "null" ], "format": "int32", - "description": "Expected total book count (for ongoing series)", + "description": "Expected total book count (for ongoing series).\n\nDEPRECATED: kept for one phase of backward-compat. Mirrors\n`totalVolumeCount`. Use `totalVolumeCount` and/or `totalChapterCount`\ngoing forward; this field is removed in Phase 9 of the\nmetadata-count-split plan.", + "example": 4 + }, + "totalChapterCount": { + "type": [ + "number", + "null" + ], + "format": "float", + "description": "Expected total chapter count (for chapter-organized series). May be fractional.", + "example": 109.5 + }, + "totalVolumeCount": { + "type": [ + "integer", + "null" + ], + "format": "int32", + "description": "Expected total volume count (for volume-organized series).", "example": 4 }, "year": { @@ -31913,7 +32037,7 @@ }, { "type": "object", - "description": "Filter by series completion status (complete/incomplete based on book_count vs total_book_count)", + "description": "Filter by series completion status (complete/incomplete based on book_count vs total_volume_count)", "required": [ "completion" ], @@ -32519,7 +32643,25 @@ "null" ], "format": "int32", - "description": "Expected total book count (for ongoing series)", + "description": "Expected total book count (for ongoing series).\n\nDEPRECATED: kept for one phase of backward-compat. Mirrors\n`totalVolumeCount`. Removed in Phase 9.", + "example": 4 + }, + "totalChapterCount": { + "type": [ + "number", + "null" + ], + "format": "float", + "description": "Expected total chapter count (for chapter-organized series). May be fractional.", + "example": 109.5 + }, + "totalVolumeCount": { + "type": [ + "integer", + "null" + ], + "format": "int32", + "description": "Expected total volume count (for volume-organized series).", "example": 4 }, "updatedAt": { @@ -32688,7 +32830,25 @@ "null" ], "format": "int32", - "description": "Expected total book count (for ongoing series)", + "description": "Expected total book count (for ongoing series).\n\nDEPRECATED: kept for one phase of backward-compat. Mirrors\n`totalVolumeCount`. Removed in Phase 9 of the metadata-count-split plan.", + "example": 4 + }, + "totalChapterCount": { + "type": [ + "number", + "null" + ], + "format": "float", + "description": "Expected total chapter count (for chapter-organized series). May be fractional.", + "example": 109.5 + }, + "totalVolumeCount": { + "type": [ + "integer", + "null" + ], + "format": "int32", + "description": "Expected total volume count (for volume-organized series).", "example": 4 }, "updatedAt": { @@ -35498,7 +35658,23 @@ "boolean", "null" ], - "description": "Whether to lock the total_book_count field", + "description": "Whether to lock the total_book_count field.\n\nDEPRECATED: kept for one phase of backward-compat. Sets\n`totalVolumeCountLock` under the hood. Removed in Phase 9.", + "example": false + }, + "totalChapterCount": { + "type": [ + "boolean", + "null" + ], + "description": "Whether to lock the total_chapter_count field", + "example": false + }, + "totalVolumeCount": { + "type": [ + "boolean", + "null" + ], + "description": "Whether to lock the total_volume_count field", "example": false }, "year": { diff --git a/web/src/types/api.generated.ts b/web/src/types/api.generated.ts index 9c70549d..4753f4fd 100644 --- a/web/src/types/api.generated.ts +++ b/web/src/types/api.generated.ts @@ -8263,9 +8263,23 @@ export interface components { status?: string | null; /** * Format: int32 - * @description Expected total book count (for ongoing series) + * @description Expected total book count (for ongoing series). + * + * DEPRECATED: kept for one phase of backward-compat with API clients + * pinned to the legacy field. Sets `total_volume_count` under the hood. + * Removed in Phase 9 of the metadata-count-split plan. */ totalBookCount?: number | null; + /** + * Format: float + * @description Expected total chapter count (for chapter-organized series). May be fractional. + */ + totalChapterCount?: number | null; + /** + * Format: int32 + * @description Expected total volume count (for volume-organized series). + */ + totalVolumeCount?: number | null; /** * Format: int32 * @description Release year @@ -9664,10 +9678,25 @@ export interface components { titleSort?: string | null; /** * Format: int32 - * @description Expected total book count (for ongoing series) + * @description Expected total book count (for ongoing series). + * + * DEPRECATED: kept for one phase of backward-compat. Mirrors + * `totalVolumeCount`. Removed in Phase 9. * @example 4 */ totalBookCount?: number | null; + /** + * Format: float + * @description Expected total chapter count (for chapter-organized series). May be fractional. + * @example 109.5 + */ + totalChapterCount?: number | null; + /** + * Format: int32 + * @description Expected total volume count (for volume-organized series). + * @example 4 + */ + totalVolumeCount?: number | null; /** * Format: date-time * @example 2024-01-15T10:30:00Z @@ -11176,12 +11205,36 @@ export interface components { titleSortLock?: boolean; /** * Format: int32 - * @description Expected total book count + * @description Expected total book count. + * + * DEPRECATED: kept for one phase of backward-compat. Mirrors + * `totalVolumeCount`. Removed in Phase 9 of the metadata-count-split plan. * @example 110 */ totalBookCount?: number | null; - /** @description Whether total_book_count is locked */ + /** + * @description Whether total_book_count is locked. + * + * DEPRECATED: kept for one phase of backward-compat. Mirrors + * `totalVolumeCountLock`. Removed in Phase 9. + */ totalBookCountLock?: boolean; + /** + * Format: float + * @description Expected total chapter count (may be fractional) + * @example 1100.5 + */ + totalChapterCount?: number | null; + /** @description Whether total_chapter_count is locked */ + totalChapterCountLock?: boolean; + /** + * Format: int32 + * @description Expected total volume count + * @example 110 + */ + totalVolumeCount?: number | null; + /** @description Whether total_volume_count is locked */ + totalVolumeCountLock?: boolean; /** * Format: int32 * @description Publication year @@ -11283,10 +11336,23 @@ export interface components { */ titleSort: boolean; /** - * @description Whether the total_book_count field is locked + * @description Whether the total_book_count field is locked. + * + * DEPRECATED: kept for one phase of backward-compat. Mirrors + * `totalVolumeCountLock`. Removed in Phase 9. * @example false */ totalBookCount: boolean; + /** + * @description Whether the total_chapter_count field is locked + * @example false + */ + totalChapterCount: boolean; + /** + * @description Whether the total_volume_count field is locked + * @example false + */ + totalVolumeCount: boolean; /** * @description Whether the year field is locked * @example false @@ -12669,10 +12735,26 @@ export interface components { titleSort?: string | null; /** * Format: int32 - * @description Expected total book count (for ongoing series) + * @description Expected total book count (for ongoing series). + * + * DEPRECATED: kept for one phase of backward-compat. Sets + * `totalVolumeCount` under the hood. Removed in Phase 9 of the + * metadata-count-split plan. * @example 4 */ totalBookCount?: number | null; + /** + * Format: float + * @description Expected total chapter count (for chapter-organized series). May be fractional. + * @example 109.5 + */ + totalChapterCount?: number | null; + /** + * Format: int32 + * @description Expected total volume count (for volume-organized series) + * @example 4 + */ + totalVolumeCount?: number | null; /** * Format: int32 * @description Release year @@ -13703,9 +13785,22 @@ export interface components { title: string; /** * Format: int32 - * @description Total expected number of books/volumes in the series + * @description Total expected number of books/volumes in the series. + * + * DEPRECATED: kept for one phase of backward-compat. Mirrors + * `totalVolumeCount`. Removed in Phase 9 of metadata-count-split. */ totalBookCount?: number | null; + /** + * Format: float + * @description Total expected number of chapters in the series. May be fractional. + */ + totalChapterCount?: number | null; + /** + * Format: int32 + * @description Total expected number of volumes in the series. + */ + totalVolumeCount?: number | null; }; /** @description A tag with relevance rank from the source service */ RecommendationTagDto: { @@ -14046,10 +14141,27 @@ export interface components { titleSort?: string | null; /** * Format: int32 - * @description Expected total book count (for ongoing series) + * @description Expected total book count (for ongoing series). + * + * DEPRECATED: kept for one phase of backward-compat. Mirrors + * `totalVolumeCount`. Use `totalVolumeCount` and/or `totalChapterCount` + * going forward; this field is removed in Phase 9 of the + * metadata-count-split plan. * @example 4 */ totalBookCount?: number | null; + /** + * Format: float + * @description Expected total chapter count (for chapter-organized series). May be fractional. + * @example 109.5 + */ + totalChapterCount?: number | null; + /** + * Format: int32 + * @description Expected total volume count (for volume-organized series). + * @example 4 + */ + totalVolumeCount?: number | null; /** * Format: int32 * @description Release year @@ -14751,10 +14863,25 @@ export interface components { titleSort?: string | null; /** * Format: int32 - * @description Expected total book count (for ongoing series) + * @description Expected total book count (for ongoing series). + * + * DEPRECATED: kept for one phase of backward-compat. Mirrors + * `totalVolumeCount`. Removed in Phase 9. * @example 4 */ totalBookCount?: number | null; + /** + * Format: float + * @description Expected total chapter count (for chapter-organized series). May be fractional. + * @example 109.5 + */ + totalChapterCount?: number | null; + /** + * Format: int32 + * @description Expected total volume count (for volume-organized series). + * @example 4 + */ + totalVolumeCount?: number | null; /** * Format: date-time * @description When the metadata was last updated @@ -14850,10 +14977,25 @@ export interface components { titleSort?: string | null; /** * Format: int32 - * @description Expected total book count (for ongoing series) + * @description Expected total book count (for ongoing series). + * + * DEPRECATED: kept for one phase of backward-compat. Mirrors + * `totalVolumeCount`. Removed in Phase 9 of the metadata-count-split plan. * @example 4 */ totalBookCount?: number | null; + /** + * Format: float + * @description Expected total chapter count (for chapter-organized series). May be fractional. + * @example 109.5 + */ + totalChapterCount?: number | null; + /** + * Format: int32 + * @description Expected total volume count (for volume-organized series). + * @example 4 + */ + totalVolumeCount?: number | null; /** * Format: date-time * @description Last update timestamp @@ -16263,10 +16405,23 @@ export interface components { */ titleSort?: boolean | null; /** - * @description Whether to lock the total_book_count field + * @description Whether to lock the total_book_count field. + * + * DEPRECATED: kept for one phase of backward-compat. Sets + * `totalVolumeCountLock` under the hood. Removed in Phase 9. * @example false */ totalBookCount?: boolean | null; + /** + * @description Whether to lock the total_chapter_count field + * @example false + */ + totalChapterCount?: boolean | null; + /** + * @description Whether to lock the total_volume_count field + * @example false + */ + totalVolumeCount?: boolean | null; /** * @description Whether to lock the year field * @example false From 1fefe5c8d8cd0efa79e20a9018a600a90e383686 Mon Sep 17 00:00:00 2001 From: Sylvain Cau Date: Fri, 1 May 2026 15:04:28 -0700 Subject: [PATCH 05/19] feat(plugins): populate total_volume_count and total_chapter_count from MangaBaka and AniList Extend the TypeScript SDK's PluginSeriesMetadata, UserLibraryEntry, and Recommendation types with totalVolumeCount (number) and totalChapterCount (number, fractional supported), mark totalBookCount @deprecated, and widen the manifest protocolVersion union to "1.0" | "1.1" so updated plugins can declare 1.1 without breaking 1.0 consumers. Update the MangaBaka metadata plugin to map both fields from MangaBaka's final_volume and total_chapters response fields via dedicated parser helpers that reject null/empty/non-numeric/non-positive inputs. The legacy totalBookCount continues to mirror the volume count for one phase of backward-compat with older Codex versions. Bump the manifest to protocol 1.1 and add fixture tests for the basic case, fractional chapter counts, null handling, and garbage input. Update the AniList recommendations plugin to fetch Media.chapters from the GraphQL recommendations query and derive totalVolumeCount and totalChapterCount in convertRecommendations, filtering zero/negative values and mirroring the volume count to legacy totalBookCount. Bump the manifest to protocol 1.1. The existing volumes coverage was rewritten to assert the new fields plus mirroring, with new cases for chapters-only, both-fields-known, and zero/negative rejection. MAL is skipped (no metadata-mal plugin in this repo). OpenLibrary is left as-is since its mapper does not currently populate totalBookCount, so there is nothing to migrate yet. --- plugins/metadata-mangabaka/src/manifest.ts | 2 +- .../metadata-mangabaka/src/mappers.test.ts | 96 +++++++++++++++++++ plugins/metadata-mangabaka/src/mappers.ts | 34 ++++++- plugins/metadata-mangabaka/src/types.ts | 1 + .../recommendations-anilist/src/anilist.ts | 2 + .../recommendations-anilist/src/index.test.ts | 37 ++++++- plugins/recommendations-anilist/src/index.ts | 10 +- .../recommendations-anilist/src/manifest.ts | 2 +- plugins/sdk-typescript/src/types/manifest.ts | 2 +- plugins/sdk-typescript/src/types/protocol.ts | 19 +++- .../src/types/recommendations.ts | 22 ++++- 11 files changed, 215 insertions(+), 12 deletions(-) diff --git a/plugins/metadata-mangabaka/src/manifest.ts b/plugins/metadata-mangabaka/src/manifest.ts index a7ebf56c..540db1db 100644 --- a/plugins/metadata-mangabaka/src/manifest.ts +++ b/plugins/metadata-mangabaka/src/manifest.ts @@ -8,7 +8,7 @@ export const manifest = { description: "Fetch manga metadata from MangaBaka - aggregated data from multiple sources", author: "Codex", homepage: "https://mangabaka.org", - protocolVersion: "1.0", + protocolVersion: "1.1", capabilities: { metadataProvider: ["series"] as MetadataContentType[], }, diff --git a/plugins/metadata-mangabaka/src/mappers.test.ts b/plugins/metadata-mangabaka/src/mappers.test.ts index 74ef0fa3..2702e9c0 100644 --- a/plugins/metadata-mangabaka/src/mappers.test.ts +++ b/plugins/metadata-mangabaka/src/mappers.test.ts @@ -340,6 +340,102 @@ describe("mappers", () => { expect(result.artists).toEqual([]); }); + it("should populate totalVolumeCount from final_volume and totalChapterCount from total_chapters", () => { + const series: MbSeries = { + id: 4, + state: "active", + title: "One Piece", + cover: { + raw: { url: null }, + x150: { x1: null, x2: null, x3: null }, + x250: { x1: null, x2: null, x3: null }, + x350: { x1: null, x2: null, x3: null }, + }, + type: "manga", + status: "releasing", + final_volume: "108", + total_chapters: "1138", + }; + + const result = mapSeriesMetadata(series); + + expect(result.totalVolumeCount).toBe(108); + expect(result.totalChapterCount).toBe(1138); + // Legacy totalBookCount kept populated mirroring the volume count for + // one phase of backward-compat. + expect(result.totalBookCount).toBe(108); + }); + + it("should populate fractional totalChapterCount", () => { + const series: MbSeries = { + id: 5, + state: "active", + title: "Series with half chapters", + cover: { + raw: { url: null }, + x150: { x1: null, x2: null, x3: null }, + x250: { x1: null, x2: null, x3: null }, + x350: { x1: null, x2: null, x3: null }, + }, + type: "manga", + status: "releasing", + final_volume: "10", + total_chapters: "47.5", + }; + + const result = mapSeriesMetadata(series); + + expect(result.totalVolumeCount).toBe(10); + expect(result.totalChapterCount).toBe(47.5); + }); + + it("should leave count fields undefined when MangaBaka returns null/empty", () => { + const series: MbSeries = { + id: 6, + state: "active", + title: "No counts known", + cover: { + raw: { url: null }, + x150: { x1: null, x2: null, x3: null }, + x250: { x1: null, x2: null, x3: null }, + x350: { x1: null, x2: null, x3: null }, + }, + type: "manga", + status: "releasing", + final_volume: null, + total_chapters: null, + }; + + const result = mapSeriesMetadata(series); + + expect(result.totalVolumeCount).toBeUndefined(); + expect(result.totalChapterCount).toBeUndefined(); + expect(result.totalBookCount).toBeUndefined(); + }); + + it("should treat non-numeric or non-positive count strings as undefined", () => { + const series: MbSeries = { + id: 7, + state: "active", + title: "Garbage counts", + cover: { + raw: { url: null }, + x150: { x1: null, x2: null, x3: null }, + x250: { x1: null, x2: null, x3: null }, + x350: { x1: null, x2: null, x3: null }, + }, + type: "manga", + status: "releasing", + final_volume: "0", + total_chapters: "n/a", + }; + + const result = mapSeriesMetadata(series); + + expect(result.totalVolumeCount).toBeUndefined(); + expect(result.totalChapterCount).toBeUndefined(); + }); + it("should detect language from country of origin", () => { const series: MbSeries = { id: 2, diff --git a/plugins/metadata-mangabaka/src/mappers.ts b/plugins/metadata-mangabaka/src/mappers.ts index c2d12433..2a22580b 100644 --- a/plugins/metadata-mangabaka/src/mappers.ts +++ b/plugins/metadata-mangabaka/src/mappers.ts @@ -15,6 +15,28 @@ import type { } from "@ashdev/codex-plugin-sdk"; import type { MbContentRating, MbSeries, MbSeriesType, MbStatus } from "./types.js"; +/** + * Parse MangaBaka's volume count strings (e.g. "40") into a positive integer. + * Returns undefined for null/empty/non-numeric/non-positive inputs. + */ +function parseVolumeCount(value: string | null | undefined): number | undefined { + if (value == null) return undefined; + const parsed = Number.parseInt(value, 10); + if (!Number.isFinite(parsed) || parsed <= 0) return undefined; + return parsed; +} + +/** + * Parse MangaBaka's chapter count strings (e.g. "109", "47.5") into a positive + * float. Returns undefined for null/empty/non-numeric/non-positive inputs. + */ +function parseChapterCount(value: string | null | undefined): number | undefined { + if (value == null) return undefined; + const parsed = Number.parseFloat(value); + if (!Number.isFinite(parsed) || parsed <= 0) return undefined; + return parsed; +} + /** * Strip HTML tags from text, converting
to newlines */ @@ -172,7 +194,7 @@ export function mapSearchResult(series: MbSeries): SearchResult { genres: (series.genres ?? []).slice(0, 3).map(formatGenre), rating: extractRating(series.rating), description: stripHtml(series.description)?.slice(0, 200) ?? undefined, - bookCount: series.final_volume ? Number.parseInt(series.final_volume, 10) : undefined, + bookCount: parseVolumeCount(series.final_volume), authors: previewAuthors.length > 0 ? previewAuthors : undefined, }, }; @@ -328,6 +350,9 @@ export function mapSeriesMetadata(series: MbSeries): PluginSeriesMetadata { // Get publisher name (pick first one if available) const publisher = series.publishers?.[0]?.name ?? undefined; + const totalVolumeCount = parseVolumeCount(series.final_volume); + const totalChapterCount = parseChapterCount(series.total_chapters); + return { externalId: String(series.id), externalUrl: `https://mangabaka.org/${series.id}`, @@ -338,7 +363,12 @@ export function mapSeriesMetadata(series: MbSeries): PluginSeriesMetadata { year: series.year ?? undefined, // Extended metadata publisher, - totalBookCount: series.final_volume ? Number.parseInt(series.final_volume, 10) : undefined, + // Legacy field kept populated for backward-compat with older Codex versions + // that don't yet read totalVolumeCount; mirrors the volume value (most + // metadata in the wild is volume-shaped). + totalBookCount: totalVolumeCount, + totalVolumeCount, + totalChapterCount, ageRating: mapContentRating(series.content_rating), readingDirection: inferReadingDirection(series.type, series.country_of_origin), // Taxonomy diff --git a/plugins/metadata-mangabaka/src/types.ts b/plugins/metadata-mangabaka/src/types.ts index c23a3d8d..4b04274f 100644 --- a/plugins/metadata-mangabaka/src/types.ts +++ b/plugins/metadata-mangabaka/src/types.ts @@ -180,6 +180,7 @@ export interface MbSeries { description?: string | null; year?: number | null; final_volume?: string | null; + total_chapters?: string | null; status: MbStatus; is_licensed?: boolean; has_anime?: boolean; diff --git a/plugins/recommendations-anilist/src/anilist.ts b/plugins/recommendations-anilist/src/anilist.ts index c689c5e2..ae28b858 100644 --- a/plugins/recommendations-anilist/src/anilist.ts +++ b/plugins/recommendations-anilist/src/anilist.ts @@ -63,6 +63,7 @@ const MEDIA_RECOMMENDATIONS_QUERY = ` year } volumes + chapters } } } @@ -137,6 +138,7 @@ export interface AniListRecommendationNode { countryOfOrigin: string | null; startDate: { year: number | null } | null; volumes: number | null; + chapters: number | null; } | null; } diff --git a/plugins/recommendations-anilist/src/index.test.ts b/plugins/recommendations-anilist/src/index.test.ts index 68bf5d9a..7d47ae31 100644 --- a/plugins/recommendations-anilist/src/index.test.ts +++ b/plugins/recommendations-anilist/src/index.test.ts @@ -51,6 +51,7 @@ function makeNode( countryOfOrigin: string | null; startYear: number | null; volumes: number | null; + chapters: number | null; mediaRecommendation: AniListRecommendationNode["mediaRecommendation"]; }>, ): AniListRecommendationNode { @@ -80,6 +81,7 @@ function makeNode( countryOfOrigin: "countryOfOrigin" in overrides ? (overrides.countryOfOrigin ?? null) : null, startDate: "startYear" in overrides ? { year: overrides.startYear ?? null } : { year: null }, volumes: "volumes" in overrides ? (overrides.volumes ?? null) : null, + chapters: "chapters" in overrides ? (overrides.chapters ?? null) : null, }, }; } @@ -260,15 +262,46 @@ describe("convertRecommendations", () => { expect(results[0].status).toBeUndefined(); }); - it("includes totalBookCount from volumes", () => { + it("includes totalVolumeCount from volumes", () => { const nodes = [makeNode({ id: 1, rating: 50, volumes: 27 })]; const results = convertRecommendations(nodes, "Test", new Set(), new Set()); + expect(results[0].totalVolumeCount).toBe(27); + // Legacy totalBookCount mirrors the volume value for backward-compat expect(results[0].totalBookCount).toBe(27); }); - it("leaves totalBookCount undefined when volumes is null", () => { + it("leaves totalVolumeCount undefined when volumes is null", () => { const nodes = [makeNode({ id: 1, rating: 50, volumes: null })]; const results = convertRecommendations(nodes, "Test", new Set(), new Set()); + expect(results[0].totalVolumeCount).toBeUndefined(); + expect(results[0].totalBookCount).toBeUndefined(); + }); + + it("includes totalChapterCount from chapters", () => { + const nodes = [makeNode({ id: 1, rating: 50, chapters: 1086 })]; + const results = convertRecommendations(nodes, "Test", new Set(), new Set()); + expect(results[0].totalChapterCount).toBe(1086); + }); + + it("leaves totalChapterCount undefined when chapters is null", () => { + const nodes = [makeNode({ id: 1, rating: 50, chapters: null })]; + const results = convertRecommendations(nodes, "Test", new Set(), new Set()); + expect(results[0].totalChapterCount).toBeUndefined(); + }); + + it("populates both totalVolumeCount and totalChapterCount when both are known", () => { + const nodes = [makeNode({ id: 1, rating: 50, volumes: 14, chapters: 109 })]; + const results = convertRecommendations(nodes, "Test", new Set(), new Set()); + expect(results[0].totalVolumeCount).toBe(14); + expect(results[0].totalChapterCount).toBe(109); + expect(results[0].totalBookCount).toBe(14); + }); + + it("treats zero or negative volumes/chapters as undefined", () => { + const nodes = [makeNode({ id: 1, rating: 50, volumes: 0, chapters: 0 })]; + const results = convertRecommendations(nodes, "Test", new Set(), new Set()); + expect(results[0].totalVolumeCount).toBeUndefined(); + expect(results[0].totalChapterCount).toBeUndefined(); expect(results[0].totalBookCount).toBeUndefined(); }); diff --git a/plugins/recommendations-anilist/src/index.ts b/plugins/recommendations-anilist/src/index.ts index 15a7af62..a8c28e20 100644 --- a/plugins/recommendations-anilist/src/index.ts +++ b/plugins/recommendations-anilist/src/index.ts @@ -282,7 +282,9 @@ export function convertRecommendations( const score = Math.round((communityScore * 0.6 + avgScore * 0.4) * 100) / 100; const status = mapAniListStatus(media.status); - const totalBookCount = media.volumes ?? undefined; + const totalVolumeCount = media.volumes != null && media.volumes > 0 ? media.volumes : undefined; + const totalChapterCount = + media.chapters != null && media.chapters > 0 ? media.chapters : undefined; results.push({ externalId, @@ -300,7 +302,11 @@ export function convertRecommendations( format: media.format ?? undefined, countryOfOrigin: media.countryOfOrigin ?? undefined, startYear: media.startDate?.year ?? undefined, - totalBookCount, + // Legacy field mirrors the volume count so older Codex versions still + // see a value; new field is the authoritative one going forward. + totalBookCount: totalVolumeCount, + totalVolumeCount, + totalChapterCount, rating: media.averageScore ?? undefined, popularity: media.popularity ?? undefined, }); diff --git a/plugins/recommendations-anilist/src/manifest.ts b/plugins/recommendations-anilist/src/manifest.ts index d1a416bd..c76747b4 100644 --- a/plugins/recommendations-anilist/src/manifest.ts +++ b/plugins/recommendations-anilist/src/manifest.ts @@ -12,7 +12,7 @@ export const manifest = { "Personalized manga recommendations from AniList based on your reading history and ratings.", author: "Codex", homepage: "https://github.com/AshDevFr/codex", - protocolVersion: "1.0", + protocolVersion: "1.1", capabilities: { userRecommendationProvider: true, externalIdSource: EXTERNAL_ID_SOURCE_ANILIST, diff --git a/plugins/sdk-typescript/src/types/manifest.ts b/plugins/sdk-typescript/src/types/manifest.ts index c0324d98..55d4bf7d 100644 --- a/plugins/sdk-typescript/src/types/manifest.ts +++ b/plugins/sdk-typescript/src/types/manifest.ts @@ -122,7 +122,7 @@ export interface PluginManifest { icon?: string; /** Protocol version this plugin implements */ - protocolVersion: "1.0"; + protocolVersion: "1.0" | "1.1"; /** What this plugin can do */ capabilities: PluginCapabilities; diff --git a/plugins/sdk-typescript/src/types/protocol.ts b/plugins/sdk-typescript/src/types/protocol.ts index e552c97f..63218d8c 100644 --- a/plugins/sdk-typescript/src/types/protocol.ts +++ b/plugins/sdk-typescript/src/types/protocol.ts @@ -105,8 +105,25 @@ export interface PluginSeriesMetadata { year?: number; // Extended metadata - /** Expected total number of books in the series */ + /** + * Expected total number of books in the series. + * + * @deprecated Use `totalVolumeCount` and/or `totalChapterCount` instead. + * Kept for one phase of backward-compat with older plugins; will be removed + * in a future protocol version. + */ totalBookCount?: number; + /** + * Expected total number of volumes in the series, when known. + * Use this for volume-organized libraries. + */ + totalVolumeCount?: number; + /** + * Expected total number of chapters in the series, when known. + * May be fractional (e.g. 47.5). + * Use this for chapter-organized libraries. + */ + totalChapterCount?: number; /** BCP47 language code (e.g., "en", "ja", "ko") */ language?: string; /** Age rating (e.g., 0, 13, 16, 18) */ diff --git a/plugins/sdk-typescript/src/types/recommendations.ts b/plugins/sdk-typescript/src/types/recommendations.ts index 6eb28a0b..6c824e4f 100644 --- a/plugins/sdk-typescript/src/types/recommendations.ts +++ b/plugins/sdk-typescript/src/types/recommendations.ts @@ -39,8 +39,17 @@ export interface UserLibraryEntry { genres: string[]; /** Tags */ tags: string[]; - /** Total number of books in the series */ + /** + * Total number of books in the series. + * + * @deprecated Use `totalVolumeCount` and/or `totalChapterCount` instead. + * Kept for one phase of backward-compat with older plugins. + */ totalBookCount?: number; + /** Expected total number of volumes in the series, when known */ + totalVolumeCount?: number; + /** Expected total number of chapters in the series, when known. May be fractional. */ + totalChapterCount?: number; /** External IDs from metadata providers */ externalIds: Array<{ source: string; externalId: string }>; /** User's reading status */ @@ -109,8 +118,17 @@ export interface Recommendation { countryOfOrigin?: string; /** Year the series started */ startYear?: number; - /** Total expected number of books/volumes in the series */ + /** + * Total expected number of books/volumes in the series. + * + * @deprecated Use `totalVolumeCount` and/or `totalChapterCount` instead. + * Kept for one phase of backward-compat with older plugins. + */ totalBookCount?: number; + /** Total expected number of volumes in the series, when known */ + totalVolumeCount?: number; + /** Total expected number of chapters in the series, when known. May be fractional. */ + totalChapterCount?: number; /** Average user rating on the source service (0-100 scale) */ rating?: number; /** Popularity ranking/count on the source service */ From 771f56532dade06d99eb808e6402271daeaa7de9 Mon Sep 17 00:00:00 2001 From: Sylvain Cau Date: Fri, 1 May 2026 15:20:45 -0700 Subject: [PATCH 06/19] feat(plugins): surface search-result format discriminator on previews Add an optional `format` field to the plugin `SearchResultPreview` protocol so search-result rows can carry a content-type discriminator (manga, novel, light_novel, manhwa, manhua, comic, webtoon, one_shot, etc.). The field is free-form at the protocol layer so plugin authors are not locked into an enum that requires Codex core changes when new formats appear; the recommended vocabulary is documented on the field. The MangaBaka plugin populates it from `series.type` (already lowercase snake_case, no conversion needed). The metadata search modal renders a colored badge ahead of the existing status/bookCount/genres badges: grape for the manga family, teal for novels, orange for comics, gray fallback for unknowns with the raw value title-cased. A small helper module owns the color/label resolution so the mapping is unit-testable. This fixes a recurring papercut where MangaBaka returns two visually identical rows for the same title (e.g. one tagged manga, one tagged novel) and the only way to tell them apart was to click through to the provider. The data was already on the wire from MangaBaka; it just was not propagated through the plugin protocol or rendered in the modal. Tests added at every layer (protocol round-trip + old-shape compat, mapper, badge helper, modal rendering with distinct colors). OpenAPI spec and TypeScript types regenerated. --- docs/api/openapi.json | 7 + docs/dev/plugins/sdk.md | 9 ++ .../metadata-mangabaka/src/mappers.test.ts | 62 ++++++++ plugins/metadata-mangabaka/src/mappers.ts | 1 + plugins/sdk-typescript/src/types/protocol.ts | 12 ++ src/api/routes/v1/dto/plugins.rs | 9 ++ src/services/plugin/protocol.rs | 61 ++++++++ web/openapi.json | 7 + .../metadata/MetadataSearchModal.test.tsx | 133 ++++++++++++++++++ .../metadata/MetadataSearchModal.tsx | 9 ++ .../components/metadata/formatBadge.test.ts | 85 +++++++++++ web/src/components/metadata/formatBadge.ts | 60 ++++++++ web/src/types/api.generated.ts | 8 ++ 13 files changed, 463 insertions(+) create mode 100644 web/src/components/metadata/formatBadge.test.ts create mode 100644 web/src/components/metadata/formatBadge.ts diff --git a/docs/api/openapi.json b/docs/api/openapi.json index fcf7bf68..f029ff8c 100644 --- a/docs/api/openapi.json +++ b/docs/api/openapi.json @@ -31770,6 +31770,13 @@ ], "description": "Short description" }, + "format": { + "type": [ + "string", + "null" + ], + "description": "Content format discriminator (e.g. `manga`, `novel`, `light_novel`,\n`manhwa`, `manhua`, `comic`, `webtoon`, `one_shot`).\n\nFree-form string at the protocol layer; the UI maps known values to\ncolored badges and falls back to a neutral badge for anything else." + }, "genres": { "type": "array", "items": { diff --git a/docs/dev/plugins/sdk.md b/docs/dev/plugins/sdk.md index 6febfc43..a9c47852 100644 --- a/docs/dev/plugins/sdk.md +++ b/docs/dev/plugins/sdk.md @@ -383,6 +383,15 @@ interface SearchResultPreview { description?: string; bookCount?: number; authors?: string[]; + /** + * Content format discriminator for visually disambiguating results. + * Free-form string. Recommended values (lowercase snake_case): + * manga, manhwa, manhua, novel, light_novel, comic, webtoon, + * one_shot, doujin, artbook. + * The UI maps known values to colored badges and falls back to a + * neutral badge for anything else. + */ + format?: string; } ``` diff --git a/plugins/metadata-mangabaka/src/mappers.test.ts b/plugins/metadata-mangabaka/src/mappers.test.ts index 2702e9c0..6694cb1c 100644 --- a/plugins/metadata-mangabaka/src/mappers.test.ts +++ b/plugins/metadata-mangabaka/src/mappers.test.ts @@ -110,6 +110,68 @@ describe("mappers", () => { expect(result.preview?.bookCount).toBe(13); }); + + it("should populate format from MangaBaka series type (manga)", () => { + const series: MbSeries = { + id: 12345, + state: "active", + title: "A Wild Last Boss Appeared!", + cover: { + raw: { url: null }, + x150: { x1: null, x2: null, x3: null }, + x250: { x1: null, x2: null, x3: null }, + x350: { x1: null, x2: null, x3: null }, + }, + type: "manga", + status: "releasing", + year: 2017, + }; + + const result = mapSearchResult(series); + + expect(result.preview?.format).toBe("manga"); + }); + + it("should populate format from MangaBaka series type (novel)", () => { + const series: MbSeries = { + id: 12346, + state: "active", + title: "A Wild Last Boss Appeared!", + cover: { + raw: { url: null }, + x150: { x1: null, x2: null, x3: null }, + x250: { x1: null, x2: null, x3: null }, + x350: { x1: null, x2: null, x3: null }, + }, + type: "novel", + status: "releasing", + year: 2016, + }; + + const result = mapSearchResult(series); + + expect(result.preview?.format).toBe("novel"); + }); + + it("should pass through other format values verbatim", () => { + const series: MbSeries = { + id: 99999, + state: "active", + title: "Korean Series", + cover: { + raw: { url: null }, + x150: { x1: null, x2: null, x3: null }, + x250: { x1: null, x2: null, x3: null }, + x350: { x1: null, x2: null, x3: null }, + }, + type: "manhwa", + status: "releasing", + }; + + const result = mapSearchResult(series); + + expect(result.preview?.format).toBe("manhwa"); + }); }); describe("mapSeriesMetadata", () => { diff --git a/plugins/metadata-mangabaka/src/mappers.ts b/plugins/metadata-mangabaka/src/mappers.ts index 2a22580b..45b6c06e 100644 --- a/plugins/metadata-mangabaka/src/mappers.ts +++ b/plugins/metadata-mangabaka/src/mappers.ts @@ -196,6 +196,7 @@ export function mapSearchResult(series: MbSeries): SearchResult { description: stripHtml(series.description)?.slice(0, 200) ?? undefined, bookCount: parseVolumeCount(series.final_volume), authors: previewAuthors.length > 0 ? previewAuthors : undefined, + format: series.type ?? undefined, }, }; } diff --git a/plugins/sdk-typescript/src/types/protocol.ts b/plugins/sdk-typescript/src/types/protocol.ts index 63218d8c..b646e317 100644 --- a/plugins/sdk-typescript/src/types/protocol.ts +++ b/plugins/sdk-typescript/src/types/protocol.ts @@ -69,6 +69,18 @@ export interface SearchResultPreview { bookCount?: number; /** Author names (for book search results) */ authors?: string[]; + /** + * Content format discriminator (e.g. `manga`, `novel`, `light_novel`, + * `manhwa`, `manhua`, `comic`, `webtoon`, `one_shot`, `doujin`, + * `artbook`). + * + * Free-form string so plugins are not locked into an enum that requires + * Codex core changes when new formats appear. Plugin authors are + * encouraged to emit lowercase snake_case values from the recommended + * vocabulary above so the UI can render consistent badges; unknown + * values still render as a neutral badge. + */ + format?: string; } // ============================================================================= diff --git a/src/api/routes/v1/dto/plugins.rs b/src/api/routes/v1/dto/plugins.rs index 7ee26434..c97419e9 100644 --- a/src/api/routes/v1/dto/plugins.rs +++ b/src/api/routes/v1/dto/plugins.rs @@ -1150,6 +1150,14 @@ pub struct SearchResultPreviewDto { /// Author names (book search results) #[serde(default, skip_serializing_if = "Vec::is_empty")] pub authors: Vec, + + /// Content format discriminator (e.g. `manga`, `novel`, `light_novel`, + /// `manhwa`, `manhua`, `comic`, `webtoon`, `one_shot`). + /// + /// Free-form string at the protocol layer; the UI maps known values to + /// colored badges and falls back to a neutral badge for anything else. + #[serde(skip_serializing_if = "Option::is_none")] + pub format: Option, } /// Response containing search results from a plugin @@ -1433,6 +1441,7 @@ impl From for SearchResu description: p.description, book_count: p.book_count, authors: p.authors, + format: p.format, } } } diff --git a/src/services/plugin/protocol.rs b/src/services/plugin/protocol.rs index f70ebd21..0355301b 100644 --- a/src/services/plugin/protocol.rs +++ b/src/services/plugin/protocol.rs @@ -652,6 +652,17 @@ pub struct SearchResultPreview { /// Author names (for book search results) #[serde(default, skip_serializing_if = "Vec::is_empty")] pub authors: Vec, + /// Content format discriminator (e.g. `manga`, `novel`, `light_novel`, + /// `manhwa`, `manhua`, `comic`, `webtoon`, `one_shot`, `doujin`, + /// `artbook`). + /// + /// Free-form at the protocol level so plugins are not locked into an + /// enum that requires Codex core changes when new formats appear. + /// Plugin authors are encouraged to emit lowercase snake_case values + /// from the recommended vocabulary above so the UI can render + /// consistent badges; unknown values still render as a neutral badge. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub format: Option, } /// Parameters for metadata/get @@ -1472,6 +1483,56 @@ mod tests { assert_eq!(result.year, Some(1997)); assert_eq!(result.relevance_score, Some(0.98)); assert!(result.preview.is_some()); + assert_eq!(result.preview.as_ref().unwrap().format, None); + } + + #[test] + fn test_search_result_preview_with_format_round_trip() { + let preview = SearchResultPreview { + status: Some("ongoing".to_string()), + genres: vec!["Action".to_string()], + rating: None, + description: None, + book_count: None, + authors: vec![], + format: Some("manga".to_string()), + }; + + let json = serde_json::to_value(&preview).unwrap(); + assert_eq!(json["format"], "manga"); + + let parsed: SearchResultPreview = serde_json::from_value(json).unwrap(); + assert_eq!(parsed.format.as_deref(), Some("manga")); + } + + #[test] + fn test_search_result_preview_format_optional_old_shape() { + // Old plugin output (no `format` field) must still deserialize. + let json = json!({ + "status": "ongoing", + "genres": ["Action", "Adventure"], + "bookCount": 14, + }); + + let preview: SearchResultPreview = serde_json::from_value(json).unwrap(); + assert_eq!(preview.format, None); + assert_eq!(preview.book_count, Some(14)); + } + + #[test] + fn test_search_result_preview_format_skipped_when_none() { + let preview = SearchResultPreview { + status: None, + genres: vec![], + rating: None, + description: None, + book_count: None, + authors: vec![], + format: None, + }; + + let json = serde_json::to_value(&preview).unwrap(); + assert!(!json.as_object().unwrap().contains_key("format")); } #[test] diff --git a/web/openapi.json b/web/openapi.json index fcf7bf68..f029ff8c 100644 --- a/web/openapi.json +++ b/web/openapi.json @@ -31770,6 +31770,13 @@ ], "description": "Short description" }, + "format": { + "type": [ + "string", + "null" + ], + "description": "Content format discriminator (e.g. `manga`, `novel`, `light_novel`,\n`manhwa`, `manhua`, `comic`, `webtoon`, `one_shot`).\n\nFree-form string at the protocol layer; the UI maps known values to\ncolored badges and falls back to a neutral badge for anything else." + }, "genres": { "type": "array", "items": { diff --git a/web/src/components/metadata/MetadataSearchModal.test.tsx b/web/src/components/metadata/MetadataSearchModal.test.tsx index 283ec815..ab524ab8 100644 --- a/web/src/components/metadata/MetadataSearchModal.test.tsx +++ b/web/src/components/metadata/MetadataSearchModal.test.tsx @@ -335,6 +335,139 @@ describe("MetadataSearchModal", () => { expect(screen.getByText("1 book")).toBeInTheDocument(); }); + it("renders distinct format badges for manga vs novel results", async () => { + (pluginsApi.searchMetadata as ReturnType).mockResolvedValue({ + success: true, + result: { + results: [ + { + externalId: "ext-manga", + title: "A Wild Last Boss Appeared!", + alternateTitles: [], + year: 2017, + coverUrl: null, + preview: { + status: "Releasing", + genres: [], + format: "manga", + }, + }, + { + externalId: "ext-novel", + title: "A Wild Last Boss Appeared!", + alternateTitles: [], + year: 2016, + coverUrl: null, + preview: { + status: "Releasing", + genres: [], + format: "novel", + }, + }, + ], + }, + latencyMs: 100, + }); + + renderWithProviders( + , + ); + + await waitFor( + () => { + expect(screen.getByText("Manga")).toBeInTheDocument(); + }, + { timeout: 1000 }, + ); + + const mangaBadge = screen.getByText("Manga"); + const novelBadge = screen.getByText("Novel"); + + expect(mangaBadge).toBeInTheDocument(); + expect(novelBadge).toBeInTheDocument(); + + // Mantine Badge sets data-variant + the resolved color via CSS variables on + // the root element. The badge text node lives inside a label span; walk up + // to find the styled root. + const mangaRoot = mangaBadge.closest("[data-variant]"); + const novelRoot = novelBadge.closest("[data-variant]"); + + expect(mangaRoot).not.toBeNull(); + expect(novelRoot).not.toBeNull(); + // Distinct colors → distinct inline style for the Mantine color variable. + expect(mangaRoot?.getAttribute("style")).not.toBe( + novelRoot?.getAttribute("style"), + ); + }); + + it("renders a fallback gray badge for unknown format values", async () => { + (pluginsApi.searchMetadata as ReturnType).mockResolvedValue({ + success: true, + result: { + results: [ + { + externalId: "ext-oel", + title: "Original English", + alternateTitles: [], + year: 2020, + coverUrl: null, + preview: { + genres: [], + format: "oel", + }, + }, + ], + }, + latencyMs: 100, + }); + + renderWithProviders( + , + ); + + await waitFor( + () => { + expect(screen.getByText("Oel")).toBeInTheDocument(); + }, + { timeout: 1000 }, + ); + }); + + it("omits the format badge when format is missing", async () => { + renderWithProviders( + , + ); + + // Use the existing default mock (no `format` set on either preview). + await waitFor( + () => { + expect(screen.getByText("Test Series")).toBeInTheDocument(); + }, + { timeout: 1000 }, + ); + + expect(screen.queryByText("Manga")).not.toBeInTheDocument(); + expect(screen.queryByText("Novel")).not.toBeInTheDocument(); + }); + it("displays description when provided in preview", async () => { (pluginsApi.searchMetadata as ReturnType).mockResolvedValue({ success: true, diff --git a/web/src/components/metadata/MetadataSearchModal.tsx b/web/src/components/metadata/MetadataSearchModal.tsx index 9680d357..e589c5b4 100644 --- a/web/src/components/metadata/MetadataSearchModal.tsx +++ b/web/src/components/metadata/MetadataSearchModal.tsx @@ -27,6 +27,7 @@ import { type PluginSearchResultDto, pluginsApi, } from "@/api/plugins"; +import { resolveFormatBadge } from "./formatBadge"; export interface MetadataSearchModalProps { /** Whether the modal is open */ @@ -477,6 +478,14 @@ function SearchResultCard({ result, onSelect }: SearchResultCardProps) { {result.preview && ( + {(() => { + const badge = resolveFormatBadge(result.preview.format); + return badge ? ( + + {badge.label} + + ) : null; + })()} {result.preview.status && ( {result.preview.status} diff --git a/web/src/components/metadata/formatBadge.test.ts b/web/src/components/metadata/formatBadge.test.ts new file mode 100644 index 00000000..b5a33330 --- /dev/null +++ b/web/src/components/metadata/formatBadge.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from "vitest"; +import { resolveFormatBadge } from "./formatBadge"; + +describe("resolveFormatBadge", () => { + it("returns null for null/undefined input", () => { + expect(resolveFormatBadge(null)).toBeNull(); + expect(resolveFormatBadge(undefined)).toBeNull(); + }); + + it("returns null for empty/whitespace input", () => { + expect(resolveFormatBadge("")).toBeNull(); + expect(resolveFormatBadge(" ")).toBeNull(); + }); + + it("maps known manga-family formats to grape", () => { + expect(resolveFormatBadge("manga")).toEqual({ + color: "grape", + label: "Manga", + }); + expect(resolveFormatBadge("manhwa")).toEqual({ + color: "grape", + label: "Manhwa", + }); + expect(resolveFormatBadge("manhua")).toEqual({ + color: "grape", + label: "Manhua", + }); + expect(resolveFormatBadge("webtoon")).toEqual({ + color: "grape", + label: "Webtoon", + }); + expect(resolveFormatBadge("one_shot")).toEqual({ + color: "grape", + label: "One Shot", + }); + }); + + it("maps known novel-family formats to teal", () => { + expect(resolveFormatBadge("novel")).toEqual({ + color: "teal", + label: "Novel", + }); + expect(resolveFormatBadge("light_novel")).toEqual({ + color: "teal", + label: "Light Novel", + }); + }); + + it("maps comic to orange", () => { + expect(resolveFormatBadge("comic")).toEqual({ + color: "orange", + label: "Comic", + }); + }); + + it("falls back to gray for unknown values with title-cased label", () => { + expect(resolveFormatBadge("oel")).toEqual({ color: "gray", label: "Oel" }); + expect(resolveFormatBadge("doujin")).toEqual({ + color: "gray", + label: "Doujin", + }); + expect(resolveFormatBadge("artbook")).toEqual({ + color: "gray", + label: "Artbook", + }); + }); + + it("looks up known values case-insensitively", () => { + expect(resolveFormatBadge("MANGA")).toEqual({ + color: "grape", + label: "Manga", + }); + expect(resolveFormatBadge("Light_Novel")).toEqual({ + color: "teal", + label: "Light Novel", + }); + }); + + it("title-cases multi-word fallback values", () => { + expect(resolveFormatBadge("graphic_novel")).toEqual({ + color: "gray", + label: "Graphic Novel", + }); + }); +}); diff --git a/web/src/components/metadata/formatBadge.ts b/web/src/components/metadata/formatBadge.ts new file mode 100644 index 00000000..1490ac83 --- /dev/null +++ b/web/src/components/metadata/formatBadge.ts @@ -0,0 +1,60 @@ +/** + * Maps a plugin-supplied `format` discriminator (e.g. `manga`, `novel`, + * `light_novel`, `manhwa`) to a Mantine badge color and a human-readable + * label so the metadata search modal can render visually distinct badges + * for visually-identical results. + * + * The mapping is intentionally small: it covers the recommended vocabulary + * documented on the plugin protocol. Anything outside the known set falls + * back to a neutral `gray` badge with the raw value title-cased so unknown + * formats from new plugins still render sensibly. + */ + +export interface FormatBadgeStyle { + color: string; + label: string; +} + +const KNOWN_FORMATS: Record = { + manga: { color: "grape", label: "Manga" }, + manhwa: { color: "grape", label: "Manhwa" }, + manhua: { color: "grape", label: "Manhua" }, + webtoon: { color: "grape", label: "Webtoon" }, + one_shot: { color: "grape", label: "One Shot" }, + novel: { color: "teal", label: "Novel" }, + light_novel: { color: "teal", label: "Light Novel" }, + comic: { color: "orange", label: "Comic" }, +}; + +/** + * Title-case a snake_case or lowercase string for display in the fallback + * badge: `light_novel` → `Light Novel`, `oel` → `Oel`. + */ +function titleCase(raw: string): string { + return raw + .split(/[_\s]+/) + .filter(Boolean) + .map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) + .join(" "); +} + +/** + * Resolve a plugin-supplied format string to a badge color/label pair. + * Lookups are case-insensitive against the known-set; unknown values get a + * neutral `gray` badge with the raw string title-cased. + * + * Returns `null` for empty/whitespace-only input so the caller can omit + * the badge entirely. + */ +export function resolveFormatBadge( + format: string | null | undefined, +): FormatBadgeStyle | null { + if (!format) return null; + const trimmed = format.trim(); + if (trimmed.length === 0) return null; + + const known = KNOWN_FORMATS[trimmed.toLowerCase()]; + if (known) return known; + + return { color: "gray", label: titleCase(trimmed) }; +} diff --git a/web/src/types/api.generated.ts b/web/src/types/api.generated.ts index 4753f4fd..534705e1 100644 --- a/web/src/types/api.generated.ts +++ b/web/src/types/api.generated.ts @@ -14411,6 +14411,14 @@ export interface components { bookCount?: number | null; /** @description Short description */ description?: string | null; + /** + * @description Content format discriminator (e.g. `manga`, `novel`, `light_novel`, + * `manhwa`, `manhua`, `comic`, `webtoon`, `one_shot`). + * + * Free-form string at the protocol layer; the UI maps known values to + * colored badges and falls back to a neutral badge for anything else. + */ + format?: string | null; /** @description Genres */ genres?: string[]; /** From 1abc9edf17dfa063d89ae0dd98c85f7cd7a2bda6 Mon Sep 17 00:00:00 2001 From: Sylvain Cau Date: Fri, 1 May 2026 15:32:45 -0700 Subject: [PATCH 07/19] feat(web): split series total book count into volume and chapter counts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the single "Total Books" surface across the frontend with separate volume and chapter counts, matching the metadata schema split. - Add seriesCounts helper that formats the series detail header line based on which axes are known: both → "/ vol · ch", volumes only → "/ vol", chapters only → "/ ch" (the chapter-organized fix), legacy " books" fallback, and nothing rendered when neither side has data. - SeriesDetail header switches to the helper, reading totalVolumeCount and totalChapterCount directly instead of falling back through totalBookCount. - SeriesMetadataEditModal splits the single Total Books field into Total Volumes (int) and Total Chapters (float) with independent lock toggles; save handler parses both and writes them on the PATCH. - BulkMetadataEditModal applies the same split to series form state, lock field list, and UI; bulk patch payload now carries both new fields. - ConditionsEditor field/lock option lists swap the legacy paths for the new totalVolumeCount and totalChapterCount entries. - SeriesMetadata grid component renders separate Total Volumes and Total Chapters tiles. Tests added for the formatter, the manual-edit round-trip (including fractional chapter counts), and the bulk patch round-trip. --- web/src/components/forms/ConditionsEditor.tsx | 18 ++- .../library/BulkMetadataEditModal.test.tsx | 25 +++++ .../library/BulkMetadataEditModal.tsx | 43 ++++++-- web/src/components/series/SeriesMetadata.tsx | 3 +- .../series/SeriesMetadataEditModal.test.tsx | 41 +++++++ .../series/SeriesMetadataEditModal.tsx | 58 +++++++--- .../components/series/seriesCounts.test.ts | 104 ++++++++++++++++++ web/src/components/series/seriesCounts.ts | 74 +++++++++++++ web/src/pages/SeriesDetail.tsx | 17 ++- 9 files changed, 344 insertions(+), 39 deletions(-) create mode 100644 web/src/components/series/seriesCounts.test.ts create mode 100644 web/src/components/series/seriesCounts.ts diff --git a/web/src/components/forms/ConditionsEditor.tsx b/web/src/components/forms/ConditionsEditor.tsx index 94d3be3a..92f5457b 100644 --- a/web/src/components/forms/ConditionsEditor.tsx +++ b/web/src/components/forms/ConditionsEditor.tsx @@ -224,8 +224,13 @@ const DEFAULT_FIELDS: { value: string; label: string; group: string }[] = [ }, { value: "metadata.year", label: "Year", group: "Metadata" }, { - value: "metadata.totalBookCount", - label: "Total Book Count", + value: "metadata.totalVolumeCount", + label: "Total Volume Count", + group: "Metadata", + }, + { + value: "metadata.totalChapterCount", + label: "Total Chapter Count", group: "Metadata", }, // Array fields @@ -256,8 +261,13 @@ const DEFAULT_FIELDS: { value: string; label: string; group: string }[] = [ }, { value: "metadata.yearLock", label: "Year Lock", group: "Locks" }, { - value: "metadata.totalBookCountLock", - label: "Total Book Count Lock", + value: "metadata.totalVolumeCountLock", + label: "Total Volume Count Lock", + group: "Locks", + }, + { + value: "metadata.totalChapterCountLock", + label: "Total Chapter Count Lock", group: "Locks", }, { value: "metadata.genresLock", label: "Genres Lock", group: "Locks" }, diff --git a/web/src/components/library/BulkMetadataEditModal.test.tsx b/web/src/components/library/BulkMetadataEditModal.test.tsx index 8f91d93b..ea1b61c6 100644 --- a/web/src/components/library/BulkMetadataEditModal.test.tsx +++ b/web/src/components/library/BulkMetadataEditModal.test.tsx @@ -287,6 +287,31 @@ describe("BulkMetadataEditModal", () => { // The editor should be visible with its help text expect(screen.getByText(/merge patch semantics/i)).toBeInTheDocument(); }); + + it("submits totalVolumeCount and totalChapterCount on series patch", async () => { + const user = userEvent.setup(); + renderWithProviders(); + + const volumeInput = screen.getByPlaceholderText("e.g., 14"); + const chapterInput = screen.getByPlaceholderText("e.g., 109 or 109.5"); + await user.type(volumeInput, "14"); + await user.type(chapterInput, "109.5"); + + const applyButton = screen.getByRole("button", { + name: /apply to 3 series/i, + }); + await user.click(applyButton); + + await waitFor(() => { + expect(mockPatchSeriesMetadata).toHaveBeenCalledWith( + expect.objectContaining({ + seriesIds: ["series-1", "series-2", "series-3"], + totalVolumeCount: 14, + totalChapterCount: 109.5, + }), + ); + }); + }); }); // =========================================================================== diff --git a/web/src/components/library/BulkMetadataEditModal.tsx b/web/src/components/library/BulkMetadataEditModal.tsx index 4e4c3c2e..14bff7f4 100644 --- a/web/src/components/library/BulkMetadataEditModal.tsx +++ b/web/src/components/library/BulkMetadataEditModal.tsx @@ -94,7 +94,8 @@ const SERIES_LOCK_FIELDS: Array<{ key: string; label: string }> = [ { key: "ageRating", label: "Age Rating" }, { key: "imprint", label: "Imprint" }, { key: "year", label: "Year" }, - { key: "totalBookCount", label: "Total Book Count" }, + { key: "totalVolumeCount", label: "Total Volume Count" }, + { key: "totalChapterCount", label: "Total Chapter Count" }, { key: "genres", label: "Genres" }, { key: "tags", label: "Tags" }, { key: "customMetadata", label: "Custom Metadata" }, @@ -156,7 +157,8 @@ interface SeriesFormState { readingDirection: string | null; ageRating: string; year: string; - totalBookCount: string; + totalVolumeCount: string; + totalChapterCount: string; authors: BookAuthor[]; } @@ -206,7 +208,8 @@ export function BulkMetadataEditModal({ readingDirection: null, ageRating: "", year: "", - totalBookCount: "", + totalVolumeCount: "", + totalChapterCount: "", authors: [], }); @@ -276,7 +279,8 @@ export function BulkMetadataEditModal({ readingDirection: null, ageRating: "", year: "", - totalBookCount: "", + totalVolumeCount: "", + totalChapterCount: "", authors: [], }); setBookForm({ @@ -333,9 +337,13 @@ export function BulkMetadataEditModal({ : null; if (touchedFields.year) data.year = seriesForm.year ? parseInt(seriesForm.year, 10) : null; - if (touchedFields.totalBookCount) - data.totalBookCount = seriesForm.totalBookCount - ? parseInt(seriesForm.totalBookCount, 10) + if (touchedFields.totalVolumeCount) + data.totalVolumeCount = seriesForm.totalVolumeCount + ? parseInt(seriesForm.totalVolumeCount, 10) + : null; + if (touchedFields.totalChapterCount) + data.totalChapterCount = seriesForm.totalChapterCount + ? parseFloat(seriesForm.totalChapterCount) : null; if (touchedFields.authors) { const filtered = seriesForm.authors.filter((a) => a.name.trim()); @@ -915,13 +923,24 @@ function SeriesMetadataFields({ + update("totalVolumeCount", e.target.value)} + /> + + + update("totalBookCount", e.target.value)} + placeholder="e.g., 109 or 109.5" + value={form.totalChapterCount} + onChange={(e) => update("totalChapterCount", e.target.value)} /> diff --git a/web/src/components/series/SeriesMetadata.tsx b/web/src/components/series/SeriesMetadata.tsx index 5bef6abe..bdb47200 100644 --- a/web/src/components/series/SeriesMetadata.tsx +++ b/web/src/components/series/SeriesMetadata.tsx @@ -80,7 +80,8 @@ export function SeriesMetadata({ metadata }: SeriesMetadataProps) { { label: "Language", value: languageDisplay }, { label: "Age Rating", value: ageRatingDisplay }, { label: "Reading Direction", value: readingDirDisplay }, - { label: "Total Books", value: metadata.totalBookCount }, + { label: "Total Volumes", value: metadata.totalVolumeCount }, + { label: "Total Chapters", value: metadata.totalChapterCount }, ].filter((item) => item.value !== null && item.value !== undefined); if (items.length === 0) { diff --git a/web/src/components/series/SeriesMetadataEditModal.test.tsx b/web/src/components/series/SeriesMetadataEditModal.test.tsx index 7bc8a401..09b0a984 100644 --- a/web/src/components/series/SeriesMetadataEditModal.test.tsx +++ b/web/src/components/series/SeriesMetadataEditModal.test.tsx @@ -31,6 +31,8 @@ const mockMetadata = { imprint: null, year: 2024, ageRating: null, + totalVolumeCount: 14, + totalChapterCount: 109.5, genres: [{ id: "g1", name: "Action" }], tags: [{ id: "t1", name: "Superhero" }], alternateTitles: [], @@ -47,6 +49,8 @@ const mockMetadata = { imprint: false, year: false, ageRating: false, + totalVolumeCount: false, + totalChapterCount: false, genres: false, tags: false, }, @@ -166,4 +170,41 @@ describe("SeriesMetadataEditModal", () => { expect(seriesMetadataApi.getFullMetadata).not.toHaveBeenCalled(); }); + + it("hydrates and round-trips total volume + chapter counts (incl. fractional)", async () => { + renderWithProviders( + , + ); + + // Open the Details tab where the volume/chapter inputs live + await waitFor(() => { + expect(screen.getByRole("tab", { name: /Details/i })).toBeInTheDocument(); + }); + screen.getByRole("tab", { name: /Details/i }).click(); + + // Both inputs should be hydrated with the mock values + await waitFor(() => { + expect(screen.getByDisplayValue("14")).toBeInTheDocument(); + expect(screen.getByDisplayValue("109.5")).toBeInTheDocument(); + }); + + // Save without further edits — both fields should make it into the + // PATCH payload, with the chapter count parsed as a float. + const saveButton = screen.getByRole("button", { name: /Save Changes/i }); + saveButton.click(); + + await waitFor(() => { + expect(seriesMetadataApi.patchMetadata).toHaveBeenCalledWith( + "test-series-id", + expect.objectContaining({ + totalVolumeCount: 14, + totalChapterCount: 109.5, + }), + ); + }); + }); }); diff --git a/web/src/components/series/SeriesMetadataEditModal.tsx b/web/src/components/series/SeriesMetadataEditModal.tsx index 9fc1a4f4..eba19270 100644 --- a/web/src/components/series/SeriesMetadataEditModal.tsx +++ b/web/src/components/series/SeriesMetadataEditModal.tsx @@ -76,7 +76,8 @@ interface FormState { ageRating: string; imprint: string; year: string; - totalBookCount: string; + totalVolumeCount: string; + totalChapterCount: string; genres: string[]; tags: string[]; sharingTags: string[]; @@ -97,7 +98,8 @@ interface LocksState { ageRating: boolean; imprint: boolean; year: boolean; - totalBookCount: boolean; + totalVolumeCount: boolean; + totalChapterCount: boolean; genres: boolean; tags: boolean; customMetadata: boolean; @@ -146,7 +148,8 @@ function initializeFormState( ageRating: metadata?.ageRating?.toString() || "", imprint: metadata?.imprint || "", year: metadata?.year?.toString() || "", - totalBookCount: metadata?.totalBookCount?.toString() || "", + totalVolumeCount: metadata?.totalVolumeCount?.toString() || "", + totalChapterCount: metadata?.totalChapterCount?.toString() || "", genres: metadata?.genres.map((g) => g.name) || [], tags: metadata?.tags?.map((t) => t.name) || [], authors: (metadata?.authors as BookAuthor[] | undefined) ?? [], @@ -180,7 +183,8 @@ function initializeLocksState(locks: MetadataLocks | undefined): LocksState { ageRating: locks?.ageRating || false, imprint: locks?.imprint || false, year: locks?.year || false, - totalBookCount: locks?.totalBookCount || false, + totalVolumeCount: locks?.totalVolumeCount || false, + totalChapterCount: locks?.totalChapterCount || false, genres: locks?.genres || false, tags: locks?.tags || false, customMetadata: locks?.customMetadata || false, @@ -384,8 +388,11 @@ export function SeriesMetadataEditModal({ ? Number.parseInt(formState.ageRating, 10) : null, year: formState.year ? Number.parseInt(formState.year, 10) : null, - totalBookCount: formState.totalBookCount - ? Number.parseInt(formState.totalBookCount, 10) + totalVolumeCount: formState.totalVolumeCount + ? Number.parseInt(formState.totalVolumeCount, 10) + : null, + totalChapterCount: formState.totalChapterCount + ? Number.parseFloat(formState.totalChapterCount) : null, authors: formState.authors.length > 0 @@ -655,7 +662,7 @@ export function SeriesMetadataEditModal({ /> - + - updateField("totalBookCount", v)} - locked={locksState.totalBookCount} - onLockChange={(v) => updateLock("totalBookCount", v)} - originalValue={originalFormState?.totalBookCount} - placeholder="Count" - type="number" - /> - + + + updateField("totalVolumeCount", v)} + locked={locksState.totalVolumeCount} + onLockChange={(v) => updateLock("totalVolumeCount", v)} + originalValue={originalFormState?.totalVolumeCount} + placeholder="e.g., 14" + description="Expected total volume count (whole numbers)" + type="number" + /> + + updateField("totalChapterCount", v)} + locked={locksState.totalChapterCount} + onLockChange={(v) => updateLock("totalChapterCount", v)} + originalValue={originalFormState?.totalChapterCount} + placeholder="e.g., 109 or 109.5" + description="Expected total chapter count (may be fractional)" + type="number" + /> + ); diff --git a/web/src/components/series/seriesCounts.test.ts b/web/src/components/series/seriesCounts.test.ts new file mode 100644 index 00000000..3f68407e --- /dev/null +++ b/web/src/components/series/seriesCounts.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it } from "vitest"; +import { formatChapterCount, formatSeriesCounts } from "./seriesCounts"; + +describe("formatChapterCount", () => { + it("renders integers without a decimal", () => { + expect(formatChapterCount(109)).toBe("109"); + }); + + it("preserves fractional chapter counts", () => { + expect(formatChapterCount(109.5)).toBe("109.5"); + }); +}); + +describe("formatSeriesCounts", () => { + it("returns null when there is nothing to show", () => { + expect( + formatSeriesCounts({ + localCount: null, + totalVolumeCount: null, + totalChapterCount: null, + }), + ).toBeNull(); + }); + + it("falls back to legacy 'N books' when only the local count is known", () => { + expect( + formatSeriesCounts({ + localCount: 12, + totalVolumeCount: null, + totalChapterCount: null, + }), + ).toBe("12 books"); + }); + + it("renders volumes only when chapter total is missing", () => { + expect( + formatSeriesCounts({ + localCount: 3, + totalVolumeCount: 14, + totalChapterCount: null, + }), + ).toBe("3/14 vol"); + }); + + it("renders volume total only when local count is missing", () => { + expect( + formatSeriesCounts({ + localCount: null, + totalVolumeCount: 14, + totalChapterCount: null, + }), + ).toBe("14 vol"); + }); + + it("renders chapter-only counts (the chapter-organized fix case)", () => { + expect( + formatSeriesCounts({ + localCount: 109, + totalVolumeCount: null, + totalChapterCount: 109, + }), + ).toBe("109/109 ch"); + }); + + it("renders chapter total only when local count is missing", () => { + expect( + formatSeriesCounts({ + localCount: null, + totalVolumeCount: null, + totalChapterCount: 109.5, + }), + ).toBe("109.5 ch"); + }); + + it("renders both axes when both totals are known", () => { + expect( + formatSeriesCounts({ + localCount: 109, + totalVolumeCount: 14, + totalChapterCount: 109, + }), + ).toBe("109/14 vol · 109 ch"); + }); + + it("renders both axes without local count when local is missing", () => { + expect( + formatSeriesCounts({ + localCount: undefined, + totalVolumeCount: 14, + totalChapterCount: 109, + }), + ).toBe("14 vol · 109 ch"); + }); + + it("treats zero as a real count, not as missing", () => { + expect( + formatSeriesCounts({ + localCount: 0, + totalVolumeCount: 0, + totalChapterCount: null, + }), + ).toBe("0/0 vol"); + }); +}); diff --git a/web/src/components/series/seriesCounts.ts b/web/src/components/series/seriesCounts.ts new file mode 100644 index 00000000..e8b22cf2 --- /dev/null +++ b/web/src/components/series/seriesCounts.ts @@ -0,0 +1,74 @@ +/** + * Pure helpers that format series count strings for the detail header. + * + * Inputs come from `series.bookCount` (local count) and `series.metadata` + * (`totalVolumeCount`, `totalChapterCount`). Either total may be null/undefined + * when the metadata provider didn't expose it. The legacy `totalBookCount` on + * the wire still mirrors `totalVolumeCount` until Phase 9; we read the new + * field exclusively here so call sites stop fanning out the fallback. + */ + +export interface SeriesCountInputs { + /** Local count of books on disk (i.e., `series.bookCount`). */ + localCount: number | null | undefined; + /** Provider's expected volume total. */ + totalVolumeCount: number | null | undefined; + /** Provider's expected chapter total (may be fractional). */ + totalChapterCount: number | null | undefined; +} + +/** + * Format a chapter count: drop trailing `.0` so `109` shows as `109` and + * `109.5` shows as `109.5`. + */ +export function formatChapterCount(value: number): string { + if (Number.isInteger(value)) { + return value.toString(); + } + return value.toString(); +} + +/** + * Build the human-readable count string for the series detail header. + * + * Rules (per the metadata-count-split plan, Phase 6): + * - Both totals known: `/ vol · ch` + * - Volume total only: `/ vol` (or ` vol` if local missing) + * - Chapter total only: `/ ch` (the bug-fix case for + * chapter-organized libraries; previously showed `/` and was + * incoherent) + * - Neither total known: ` books` (legacy display) + * - No local + no totals: `null` (caller can hide the line) + */ +export function formatSeriesCounts(inputs: SeriesCountInputs): string | null { + const { localCount, totalVolumeCount, totalChapterCount } = inputs; + + const hasLocal = typeof localCount === "number"; + const hasVolume = typeof totalVolumeCount === "number"; + const hasChapter = typeof totalChapterCount === "number"; + + if (hasVolume && hasChapter) { + const volumePart = hasLocal + ? `${localCount}/${totalVolumeCount} vol` + : `${totalVolumeCount} vol`; + return `${volumePart} · ${formatChapterCount(totalChapterCount)} ch`; + } + + if (hasVolume) { + return hasLocal + ? `${localCount}/${totalVolumeCount} vol` + : `${totalVolumeCount} vol`; + } + + if (hasChapter) { + return hasLocal + ? `${localCount}/${formatChapterCount(totalChapterCount)} ch` + : `${formatChapterCount(totalChapterCount)} ch`; + } + + if (hasLocal) { + return `${localCount} books`; + } + + return null; +} diff --git a/web/src/pages/SeriesDetail.tsx b/web/src/pages/SeriesDetail.tsx index c1b2a8ab..d5f383f2 100644 --- a/web/src/pages/SeriesDetail.tsx +++ b/web/src/pages/SeriesDetail.tsx @@ -66,6 +66,7 @@ import { SeriesMetadataEditModal, SeriesRating, } from "@/components/series"; +import { formatSeriesCounts } from "@/components/series/seriesCounts"; import { useDynamicDocumentTitle } from "@/hooks/useDocumentTitle"; import { usePermissions } from "@/hooks/usePermissions"; import { useCoverUpdatesStore } from "@/store/coverUpdatesStore"; @@ -764,10 +765,18 @@ export function SeriesDetail() { {/* Book count */} - - {series.bookCount ?? 0} /{" "} - {series.metadata?.totalBookCount ?? series.bookCount ?? 0} books - + {(() => { + const counts = formatSeriesCounts({ + localCount: series.bookCount ?? null, + totalVolumeCount: metadata?.totalVolumeCount ?? null, + totalChapterCount: metadata?.totalChapterCount ?? null, + }); + return counts ? ( + + {counts} + + ) : null; + })()} {/* Alternate titles inline */} {series.alternateTitles && series.alternateTitles.length > 0 && ( From adaf85bc5d8be72005b82f54e745d8cff29f7a13 Mon Sep 17 00:00:00 2001 From: Sylvain Cau Date: Fri, 1 May 2026 16:13:17 -0700 Subject: [PATCH 08/19] refactor(api): drop legacy total_book_count from v1 DTOs and handlers Strip the deprecated `totalBookCount` (and its lock counterpart) from every v1 request and response shape: SeriesMetadata, PatchSeriesMetadataRequest, SeriesMetadataResponse, MetadataLocks, FullSeriesMetadataResponse, SeriesFullMetadata, MetadataContextDto, UpdateMetadataLocksRequest, BulkPatchSeriesMetadataRequest, and RecommendationDto. Update v1 handlers across series, bulk_metadata, and recommendations to drop the matching read/write paths. The plugin-protocol fallback that maps a legacy `total_book_count` from older plugins onto `total_volume_count` is kept, along with the entity-side legacy-column setters, until the column itself is dropped. Add `metadata:write:total_volume_count` and `metadata:write:total_chapter_count` to the documented permission allowlist; the legacy `metadata:write:total_book_count` permission remains until the column drop. OPDS 1.2 and OPDS 2.0 expose no expected-total counts and need no changes. The Komga compatibility DTO retains `totalBookCount` on the wire because Komga clients depend on that name; that mapping is owned by the next phase of the migration. Regenerate the OpenAPI spec and TypeScript types so the frontend consumes only the new fields. Tests updated where they referenced the removed DTO field. --- docs/api/openapi.json | 88 ----------------- src/api/routes/v1/dto/bulk_metadata.rs | 9 -- src/api/routes/v1/dto/plugins.rs | 2 + src/api/routes/v1/dto/recommendations.rs | 7 -- src/api/routes/v1/dto/series.rs | 72 -------------- src/api/routes/v1/handlers/bulk_metadata.rs | 14 +-- src/api/routes/v1/handlers/recommendations.rs | 12 +-- src/api/routes/v1/handlers/series.rs | 32 +----- tests/api/metadata_locks.rs | 10 -- tests/api/metadata_reset.rs | 3 +- tests/api/series.rs | 19 ++-- web/openapi.json | 88 ----------------- web/src/types/api.generated.ts | 97 ------------------- 13 files changed, 21 insertions(+), 432 deletions(-) diff --git a/docs/api/openapi.json b/docs/api/openapi.json index f029ff8c..38a1e2b6 100644 --- a/docs/api/openapi.json +++ b/docs/api/openapi.json @@ -20397,14 +20397,6 @@ ], "description": "Series status (ongoing, ended, hiatus, abandoned, unknown)" }, - "totalBookCount": { - "type": [ - "integer", - "null" - ], - "format": "int32", - "description": "Expected total book count (for ongoing series).\n\nDEPRECATED: kept for one phase of backward-compat with API clients\npinned to the legacy field. Sets `total_volume_count` under the hood.\nRemoved in Phase 9 of the metadata-count-split plan." - }, "totalChapterCount": { "type": [ "number", @@ -23318,15 +23310,6 @@ "description": "Custom sort name for ordering", "example": "Batman Year One" }, - "totalBookCount": { - "type": [ - "integer", - "null" - ], - "format": "int32", - "description": "Expected total book count (for ongoing series).\n\nDEPRECATED: kept for one phase of backward-compat. Mirrors\n`totalVolumeCount`. Removed in Phase 9.", - "example": 4 - }, "totalChapterCount": { "type": [ "number", @@ -26338,19 +26321,6 @@ "type": "boolean", "description": "Whether title_sort is locked" }, - "totalBookCount": { - "type": [ - "integer", - "null" - ], - "format": "int32", - "description": "Expected total book count.\n\nDEPRECATED: kept for one phase of backward-compat. Mirrors\n`totalVolumeCount`. Removed in Phase 9 of the metadata-count-split plan.", - "example": 110 - }, - "totalBookCountLock": { - "type": "boolean", - "description": "Whether total_book_count is locked.\n\nDEPRECATED: kept for one phase of backward-compat. Mirrors\n`totalVolumeCountLock`. Removed in Phase 9." - }, "totalChapterCount": { "type": [ "number", @@ -26437,7 +26407,6 @@ "language", "readingDirection", "year", - "totalBookCount", "totalVolumeCount", "totalChapterCount", "genres", @@ -26523,11 +26492,6 @@ "description": "Whether the title_sort field is locked", "example": false }, - "totalBookCount": { - "type": "boolean", - "description": "Whether the total_book_count field is locked.\n\nDEPRECATED: kept for one phase of backward-compat. Mirrors\n`totalVolumeCountLock`. Removed in Phase 9.", - "example": false - }, "totalChapterCount": { "type": "boolean", "description": "Whether the total_chapter_count field is locked", @@ -28782,15 +28746,6 @@ "description": "Custom sort name for ordering", "example": "Batman Year One" }, - "totalBookCount": { - "type": [ - "integer", - "null" - ], - "format": "int32", - "description": "Expected total book count (for ongoing series).\n\nDEPRECATED: kept for one phase of backward-compat. Sets\n`totalVolumeCount` under the hood. Removed in Phase 9 of the\nmetadata-count-split plan.", - "example": 4 - }, "totalChapterCount": { "type": [ "number", @@ -30749,14 +30704,6 @@ "type": "string", "description": "Title of the recommended series/book" }, - "totalBookCount": { - "type": [ - "integer", - "null" - ], - "format": "int32", - "description": "Total expected number of books/volumes in the series.\n\nDEPRECATED: kept for one phase of backward-compat. Mirrors\n`totalVolumeCount`. Removed in Phase 9 of metadata-count-split." - }, "totalChapterCount": { "type": [ "number", @@ -31344,15 +31291,6 @@ "description": "Custom sort name for ordering (e.g., \"Batman Year One\" instead of \"The Batman Year One\")", "example": "Batman Year One" }, - "totalBookCount": { - "type": [ - "integer", - "null" - ], - "format": "int32", - "description": "Expected total book count (for ongoing series).\n\nDEPRECATED: kept for one phase of backward-compat. Mirrors\n`totalVolumeCount`. Use `totalVolumeCount` and/or `totalChapterCount`\ngoing forward; this field is removed in Phase 9 of the\nmetadata-count-split plan.", - "example": 4 - }, "totalChapterCount": { "type": [ "number", @@ -32644,15 +32582,6 @@ "description": "Custom sort name for ordering", "example": "Batman Year One" }, - "totalBookCount": { - "type": [ - "integer", - "null" - ], - "format": "int32", - "description": "Expected total book count (for ongoing series).\n\nDEPRECATED: kept for one phase of backward-compat. Mirrors\n`totalVolumeCount`. Removed in Phase 9.", - "example": 4 - }, "totalChapterCount": { "type": [ "number", @@ -32831,15 +32760,6 @@ "description": "Custom sort name for ordering", "example": "Batman Year One" }, - "totalBookCount": { - "type": [ - "integer", - "null" - ], - "format": "int32", - "description": "Expected total book count (for ongoing series).\n\nDEPRECATED: kept for one phase of backward-compat. Mirrors\n`totalVolumeCount`. Removed in Phase 9 of the metadata-count-split plan.", - "example": 4 - }, "totalChapterCount": { "type": [ "number", @@ -35660,14 +35580,6 @@ "description": "Whether to lock the title_sort field", "example": false }, - "totalBookCount": { - "type": [ - "boolean", - "null" - ], - "description": "Whether to lock the total_book_count field.\n\nDEPRECATED: kept for one phase of backward-compat. Sets\n`totalVolumeCountLock` under the hood. Removed in Phase 9.", - "example": false - }, "totalChapterCount": { "type": [ "boolean", diff --git a/src/api/routes/v1/dto/bulk_metadata.rs b/src/api/routes/v1/dto/bulk_metadata.rs index 1831206f..385449a5 100644 --- a/src/api/routes/v1/dto/bulk_metadata.rs +++ b/src/api/routes/v1/dto/bulk_metadata.rs @@ -81,15 +81,6 @@ pub struct BulkPatchSeriesMetadataRequest { #[schema(value_type = Option, nullable = true)] pub year: super::patch::PatchValue, - /// Expected total book count (for ongoing series). - /// - /// DEPRECATED: kept for one phase of backward-compat with API clients - /// pinned to the legacy field. Sets `total_volume_count` under the hood. - /// Removed in Phase 9 of the metadata-count-split plan. - #[serde(default)] - #[schema(value_type = Option, nullable = true)] - pub total_book_count: super::patch::PatchValue, - /// Expected total volume count (for volume-organized series). #[serde(default)] #[schema(value_type = Option, nullable = true)] diff --git a/src/api/routes/v1/dto/plugins.rs b/src/api/routes/v1/dto/plugins.rs index c97419e9..9e5dc392 100644 --- a/src/api/routes/v1/dto/plugins.rs +++ b/src/api/routes/v1/dto/plugins.rs @@ -941,6 +941,8 @@ pub fn available_permissions() -> Vec<&'static str> { "metadata:write:language", "metadata:write:reading_direction", "metadata:write:total_book_count", + "metadata:write:total_volume_count", + "metadata:write:total_chapter_count", // Book-specific write permissions "metadata:write:book_type", "metadata:write:subtitle", diff --git a/src/api/routes/v1/dto/recommendations.rs b/src/api/routes/v1/dto/recommendations.rs index 32f98791..0bef7293 100644 --- a/src/api/routes/v1/dto/recommendations.rs +++ b/src/api/routes/v1/dto/recommendations.rs @@ -69,12 +69,6 @@ pub struct RecommendationDto { /// Year the series started #[serde(skip_serializing_if = "Option::is_none")] pub start_year: Option, - /// Total expected number of books/volumes in the series. - /// - /// DEPRECATED: kept for one phase of backward-compat. Mirrors - /// `totalVolumeCount`. Removed in Phase 9 of metadata-count-split. - #[serde(skip_serializing_if = "Option::is_none")] - pub total_book_count: Option, /// Total expected number of volumes in the series. #[serde(skip_serializing_if = "Option::is_none")] pub total_volume_count: Option, @@ -164,7 +158,6 @@ mod tests { format: None, country_of_origin: None, start_year: None, - total_book_count: None, total_volume_count: None, total_chapter_count: None, rating: None, diff --git a/src/api/routes/v1/dto/series.rs b/src/api/routes/v1/dto/series.rs index f4e8d045..b2547af7 100644 --- a/src/api/routes/v1/dto/series.rs +++ b/src/api/routes/v1/dto/series.rs @@ -314,15 +314,6 @@ pub struct ReplaceSeriesMetadataRequest { #[schema(example = 1987)] pub year: Option, - /// Expected total book count (for ongoing series). - /// - /// DEPRECATED: kept for one phase of backward-compat. Mirrors - /// `totalVolumeCount`. Use `totalVolumeCount` and/or `totalChapterCount` - /// going forward; this field is removed in Phase 9 of the - /// metadata-count-split plan. - #[schema(example = 4)] - pub total_book_count: Option, - /// Expected total volume count (for volume-organized series). #[schema(example = 4)] pub total_volume_count: Option, @@ -396,15 +387,6 @@ pub struct PatchSeriesMetadataRequest { #[schema(value_type = Option, example = 1987, nullable = true)] pub year: super::patch::PatchValue, - /// Expected total book count (for ongoing series). - /// - /// DEPRECATED: kept for one phase of backward-compat. Sets - /// `totalVolumeCount` under the hood. Removed in Phase 9 of the - /// metadata-count-split plan. - #[serde(default)] - #[schema(value_type = Option, example = 4, nullable = true)] - pub total_book_count: super::patch::PatchValue, - /// Expected total volume count (for volume-organized series) #[serde(default)] #[schema(value_type = Option, example = 4, nullable = true)] @@ -474,13 +456,6 @@ pub struct SeriesMetadataResponse { #[schema(example = 1987)] pub year: Option, - /// Expected total book count (for ongoing series). - /// - /// DEPRECATED: kept for one phase of backward-compat. Mirrors - /// `totalVolumeCount`. Removed in Phase 9 of the metadata-count-split plan. - #[schema(example = 4)] - pub total_book_count: Option, - /// Expected total volume count (for volume-organized series). #[schema(example = 4)] pub total_volume_count: Option, @@ -1012,13 +987,6 @@ pub struct MetadataLocks { #[schema(example = false)] pub year: bool, - /// Whether the total_book_count field is locked. - /// - /// DEPRECATED: kept for one phase of backward-compat. Mirrors - /// `totalVolumeCountLock`. Removed in Phase 9. - #[schema(example = false)] - pub total_book_count: bool, - /// Whether the total_volume_count field is locked #[schema(example = false)] pub total_volume_count: bool, @@ -1102,13 +1070,6 @@ pub struct FullSeriesMetadataResponse { #[schema(example = 1987)] pub year: Option, - /// Expected total book count (for ongoing series). - /// - /// DEPRECATED: kept for one phase of backward-compat. Mirrors - /// `totalVolumeCount`. Removed in Phase 9. - #[schema(example = 4)] - pub total_book_count: Option, - /// Expected total volume count (for volume-organized series). #[schema(example = 4)] pub total_volume_count: Option, @@ -1206,14 +1167,6 @@ pub struct SeriesFullMetadata { #[schema(example = 1987)] pub year: Option, - /// Expected total book count (for ongoing series). - /// - /// DEPRECATED: kept for one phase of backward-compat. Mirrors - /// `totalVolumeCount`. Removed in Phase 9. - #[serde(skip_serializing_if = "Option::is_none")] - #[schema(example = 4)] - pub total_book_count: Option, - /// Expected total volume count (for volume-organized series). #[serde(skip_serializing_if = "Option::is_none")] #[schema(example = 4)] @@ -1369,14 +1322,6 @@ pub struct UpdateMetadataLocksRequest { #[schema(example = false)] pub year: Option, - /// Whether to lock the total_book_count field. - /// - /// DEPRECATED: kept for one phase of backward-compat. Sets - /// `totalVolumeCountLock` under the hood. Removed in Phase 9. - #[serde(skip_serializing_if = "Option::is_none")] - #[schema(example = false)] - pub total_book_count: Option, - /// Whether to lock the total_volume_count field #[serde(skip_serializing_if = "Option::is_none")] #[schema(example = false)] @@ -1759,14 +1704,6 @@ pub struct MetadataContextDto { #[schema(example = 1997)] pub year: Option, - /// Expected total book count. - /// - /// DEPRECATED: kept for one phase of backward-compat. Mirrors - /// `totalVolumeCount`. Removed in Phase 9 of the metadata-count-split plan. - #[serde(skip_serializing_if = "Option::is_none")] - #[schema(example = 110)] - pub total_book_count: Option, - /// Expected total volume count #[serde(skip_serializing_if = "Option::is_none")] #[schema(example = 110)] @@ -1844,13 +1781,6 @@ pub struct MetadataContextDto { #[serde(default)] pub year_lock: bool, - /// Whether total_book_count is locked. - /// - /// DEPRECATED: kept for one phase of backward-compat. Mirrors - /// `totalVolumeCountLock`. Removed in Phase 9. - #[serde(default)] - pub total_book_count_lock: bool, - /// Whether total_volume_count is locked #[serde(default)] pub total_volume_count_lock: bool, @@ -1971,7 +1901,6 @@ impl From for language: ctx.metadata.language, reading_direction: ctx.metadata.reading_direction, year: ctx.metadata.year, - total_book_count: ctx.metadata.total_book_count, total_volume_count: ctx.metadata.total_volume_count, total_chapter_count: ctx.metadata.total_chapter_count, genres: ctx.metadata.genres, @@ -2025,7 +1954,6 @@ impl From for language_lock: ctx.metadata.language_lock, reading_direction_lock: ctx.metadata.reading_direction_lock, year_lock: ctx.metadata.year_lock, - total_book_count_lock: ctx.metadata.total_book_count_lock, total_volume_count_lock: ctx.metadata.total_volume_count_lock, total_chapter_count_lock: ctx.metadata.total_chapter_count_lock, genres_lock: ctx.metadata.genres_lock, diff --git a/src/api/routes/v1/handlers/bulk_metadata.rs b/src/api/routes/v1/handlers/bulk_metadata.rs index 1f9141a5..c6173c18 100644 --- a/src/api/routes/v1/handlers/bulk_metadata.rs +++ b/src/api/routes/v1/handlers/bulk_metadata.rs @@ -84,14 +84,7 @@ pub async fn bulk_patch_series_metadata( let language_opt = request.language.into_nested_option(); let reading_direction_opt = request.reading_direction.into_nested_option(); let year_opt = request.year.into_nested_option(); - // Legacy `total_book_count` patches route to `total_volume_count` (the - // canonical field after metadata-count-split). If both are sent, the new - // field wins. Removed alongside the legacy field in Phase 9. - let legacy_total_book_count_opt = request.total_book_count.into_nested_option(); - let total_volume_count_opt = request - .total_volume_count - .into_nested_option() - .or(legacy_total_book_count_opt); + let total_volume_count_opt = request.total_volume_count.into_nested_option(); let total_chapter_count_opt = request.total_chapter_count.into_nested_option(); let custom_metadata_opt = request.custom_metadata.into_nested_option(); let authors_opt = request.authors.into_nested_option(); @@ -917,10 +910,7 @@ pub async fn bulk_update_series_locks( active.year_lock = Set(v); has_changes = true; } - // Legacy `total_book_count` lock routes to `total_volume_count_lock`. - // If both are sent, the new field wins. Removed in Phase 9. - let resolved_volume_lock = locks.total_volume_count.or(locks.total_book_count); - if let Some(v) = resolved_volume_lock { + if let Some(v) = locks.total_volume_count { active.total_volume_count_lock = Set(v); has_changes = true; } diff --git a/src/api/routes/v1/handlers/recommendations.rs b/src/api/routes/v1/handlers/recommendations.rs index 2a4846aa..f4314445 100644 --- a/src/api/routes/v1/handlers/recommendations.rs +++ b/src/api/routes/v1/handlers/recommendations.rs @@ -384,10 +384,6 @@ fn to_recommendation_dto( format: r.format, country_of_origin: r.country_of_origin, start_year: r.start_year, - // Legacy `totalBookCount` mirrors whichever volume count the plugin - // sends; if the plugin populated only the new field, we still surface - // it under both keys for one phase. Removed in Phase 9. - total_book_count: r.total_volume_count.or(r.total_book_count), total_volume_count: r.total_volume_count.or(r.total_book_count), total_chapter_count: r.total_chapter_count, rating: r.rating, @@ -589,7 +585,7 @@ mod tests { assert!(dto.in_library); assert!(!dto.in_codex); // in_codex defaults to false before enrichment assert_eq!(dto.status.as_deref(), Some("ongoing")); - assert_eq!(dto.total_book_count, Some(27)); + assert_eq!(dto.total_volume_count, Some(27)); assert_eq!(dto.rating, Some(90)); assert_eq!(dto.popularity, Some(50000)); } @@ -636,7 +632,7 @@ mod tests { assert!(!dto.in_library); assert!(!dto.in_codex); assert!(dto.status.is_none()); - assert!(dto.total_book_count.is_none()); + assert!(dto.total_volume_count.is_none()); assert!(dto.rating.is_none()); assert!(dto.popularity.is_none()); } @@ -734,7 +730,7 @@ mod tests { // codexSeriesId should be absent (None) assert!(rec0.get("codexSeriesId").is_none()); assert_eq!(rec0["status"], "ongoing"); - assert_eq!(rec0["totalBookCount"], 30); + assert_eq!(rec0["totalVolumeCount"], 30); assert_eq!(rec0["rating"], 88); assert_eq!(rec0["popularity"], 75000); @@ -753,7 +749,7 @@ mod tests { assert!(!rec1["inCodex"].as_bool().unwrap()); // Optional fields should be absent (None) assert!(rec1.get("status").is_none()); - assert!(rec1.get("totalBookCount").is_none()); + assert!(rec1.get("totalVolumeCount").is_none()); assert!(rec1.get("rating").is_none()); assert!(rec1.get("popularity").is_none()); } diff --git a/src/api/routes/v1/handlers/series.rs b/src/api/routes/v1/handlers/series.rs index 1cc625a8..b687f890 100644 --- a/src/api/routes/v1/handlers/series.rs +++ b/src/api/routes/v1/handlers/series.rs @@ -410,7 +410,6 @@ async fn series_to_full_dtos_batched( language: metadata.language.clone(), reading_direction: metadata.reading_direction.clone(), year: metadata.year, - total_book_count: metadata.total_volume_count, total_volume_count: metadata.total_volume_count, total_chapter_count: metadata.total_chapter_count, custom_metadata: parse_custom_metadata(metadata.custom_metadata.as_deref()), @@ -429,7 +428,6 @@ async fn series_to_full_dtos_batched( language: metadata.language_lock, reading_direction: metadata.reading_direction_lock, year: metadata.year_lock, - total_book_count: metadata.total_volume_count_lock, total_volume_count: metadata.total_volume_count_lock, total_chapter_count: metadata.total_chapter_count_lock, genres: metadata.genres_lock, @@ -2505,10 +2503,7 @@ pub async fn replace_series_metadata( active.language = Set(request.language.clone()); active.reading_direction = Set(request.reading_direction.clone()); active.year = Set(request.year); - // Legacy `total_book_count` requests route to `total_volume_count`; if both - // are present, the new field wins. Removed in Phase 9 of metadata-count-split. - let resolved_volume_count = request.total_volume_count.or(request.total_book_count); - active.total_volume_count = Set(resolved_volume_count); + active.total_volume_count = Set(request.total_volume_count); active.total_chapter_count = Set(request.total_chapter_count); // Validate and convert custom_metadata from JSON Value to String @@ -2542,7 +2537,8 @@ pub async fn replace_series_metadata( "language".to_string(), "reading_direction".to_string(), "year".to_string(), - "total_book_count".to_string(), + "total_volume_count".to_string(), + "total_chapter_count".to_string(), "custom_metadata".to_string(), "authors".to_string(), ]), @@ -2564,7 +2560,6 @@ pub async fn replace_series_metadata( language: updated_metadata.language, reading_direction: updated_metadata.reading_direction, year: updated_metadata.year, - total_book_count: updated_metadata.total_volume_count, total_volume_count: updated_metadata.total_volume_count, total_chapter_count: updated_metadata.total_chapter_count, custom_metadata: parse_custom_metadata(updated_metadata.custom_metadata.as_deref()), @@ -2710,7 +2705,6 @@ pub async fn reset_series_metadata( language: metadata.language, reading_direction: metadata.reading_direction, year: metadata.year, - total_book_count: metadata.total_volume_count, total_volume_count: metadata.total_volume_count, total_chapter_count: metadata.total_chapter_count, custom_metadata: parse_custom_metadata(metadata.custom_metadata.as_deref()), @@ -2729,7 +2723,6 @@ pub async fn reset_series_metadata( language: metadata.language_lock, reading_direction: metadata.reading_direction_lock, year: metadata.year_lock, - total_book_count: metadata.total_volume_count_lock, total_volume_count: metadata.total_volume_count_lock, total_chapter_count: metadata.total_chapter_count_lock, genres: metadata.genres_lock, @@ -2839,14 +2832,7 @@ pub async fn patch_series_metadata( metadata_active.year = Set(opt); has_changes = true; } - // Legacy `total_book_count` patches route to `total_volume_count`; if both - // are sent, the new field wins. Removed in Phase 9 of metadata-count-split. - let legacy_volume_count_patch = request.total_book_count.into_nested_option(); - let volume_count_patch = request - .total_volume_count - .into_nested_option() - .or(legacy_volume_count_patch); - if let Some(opt) = volume_count_patch { + if let Some(opt) = request.total_volume_count.into_nested_option() { metadata_active.total_volume_count = Set(opt); has_changes = true; } @@ -2912,7 +2898,6 @@ pub async fn patch_series_metadata( language: updated_metadata.language, reading_direction: updated_metadata.reading_direction, year: updated_metadata.year, - total_book_count: updated_metadata.total_volume_count, total_volume_count: updated_metadata.total_volume_count, total_chapter_count: updated_metadata.total_chapter_count, custom_metadata: parse_custom_metadata(updated_metadata.custom_metadata.as_deref()), @@ -3059,7 +3044,6 @@ pub async fn get_series_metadata( language: metadata.language, reading_direction: metadata.reading_direction, year: metadata.year, - total_book_count: metadata.total_volume_count, total_volume_count: metadata.total_volume_count, total_chapter_count: metadata.total_chapter_count, custom_metadata: parse_custom_metadata(metadata.custom_metadata.as_deref()), @@ -3078,7 +3062,6 @@ pub async fn get_series_metadata( language: metadata.language_lock, reading_direction: metadata.reading_direction_lock, year: metadata.year_lock, - total_book_count: metadata.total_volume_count_lock, total_volume_count: metadata.total_volume_count_lock, total_chapter_count: metadata.total_chapter_count_lock, genres: metadata.genres_lock, @@ -3185,10 +3168,7 @@ pub async fn update_metadata_locks( active.year_lock = Set(v); has_changes = true; } - // Legacy `total_book_count` lock route to `total_volume_count_lock`. If - // both are sent, the new field wins. Removed in Phase 9. - let resolved_volume_lock = request.total_volume_count.or(request.total_book_count); - if let Some(v) = resolved_volume_lock { + if let Some(v) = request.total_volume_count { active.total_volume_count_lock = Set(v); has_changes = true; } @@ -3246,7 +3226,6 @@ pub async fn update_metadata_locks( language: updated.language_lock, reading_direction: updated.reading_direction_lock, year: updated.year_lock, - total_book_count: updated.total_volume_count_lock, total_volume_count: updated.total_volume_count_lock, total_chapter_count: updated.total_chapter_count_lock, genres: updated.genres_lock, @@ -3306,7 +3285,6 @@ pub async fn get_metadata_locks( language: metadata.language_lock, reading_direction: metadata.reading_direction_lock, year: metadata.year_lock, - total_book_count: metadata.total_volume_count_lock, total_volume_count: metadata.total_volume_count_lock, total_chapter_count: metadata.total_chapter_count_lock, genres: metadata.genres_lock, diff --git a/tests/api/metadata_locks.rs b/tests/api/metadata_locks.rs index 6e6c2403..9835e251 100644 --- a/tests/api/metadata_locks.rs +++ b/tests/api/metadata_locks.rs @@ -294,7 +294,6 @@ async fn test_update_metadata_locks() { genres: None, tags: None, custom_metadata: None, - total_book_count: None, total_volume_count: None, total_chapter_count: None, cover: None, @@ -366,7 +365,6 @@ async fn test_update_metadata_locks_partial() { genres: None, tags: None, custom_metadata: None, - total_book_count: None, total_volume_count: None, total_chapter_count: None, cover: None, @@ -397,7 +395,6 @@ async fn test_update_metadata_locks_partial() { genres: None, tags: None, custom_metadata: None, - total_book_count: None, total_volume_count: None, total_chapter_count: None, cover: None, @@ -452,7 +449,6 @@ async fn test_update_metadata_locks_all_fields() { genres: Some(true), tags: Some(true), custom_metadata: Some(true), - total_book_count: Some(true), total_volume_count: Some(true), total_chapter_count: Some(true), cover: Some(true), @@ -509,7 +505,6 @@ async fn test_update_metadata_locks_not_found() { genres: None, tags: None, custom_metadata: None, - total_book_count: None, total_volume_count: None, total_chapter_count: None, cover: None, @@ -557,7 +552,6 @@ async fn test_update_metadata_locks_empty_request() { genres: None, tags: None, custom_metadata: None, - total_book_count: None, total_volume_count: None, total_chapter_count: None, cover: None, @@ -615,7 +609,6 @@ async fn test_alternate_titles_lock_independent_from_title_lock() { genres: None, tags: None, custom_metadata: None, - total_book_count: None, total_volume_count: None, total_chapter_count: None, cover: None, @@ -672,7 +665,6 @@ async fn test_alternate_titles_lock_without_affecting_title_lock() { genres: None, tags: None, custom_metadata: None, - total_book_count: None, total_volume_count: None, total_chapter_count: None, cover: None, @@ -742,7 +734,6 @@ async fn test_alternate_titles_lock_in_full_metadata_response() { genres: None, tags: None, custom_metadata: None, - total_book_count: None, total_volume_count: None, total_chapter_count: None, cover: None, @@ -814,7 +805,6 @@ async fn test_update_locks_requires_write_permission() { genres: None, tags: None, custom_metadata: None, - total_book_count: None, total_volume_count: None, total_chapter_count: None, cover: None, diff --git a/tests/api/metadata_reset.rs b/tests/api/metadata_reset.rs index dde841a8..714add0a 100644 --- a/tests/api/metadata_reset.rs +++ b/tests/api/metadata_reset.rs @@ -193,7 +193,8 @@ async fn test_reset_series_metadata_success() { assert!(body.language.is_none()); assert!(body.reading_direction.is_none()); assert!(body.year.is_none()); - assert!(body.total_book_count.is_none()); + assert!(body.total_volume_count.is_none()); + assert!(body.total_chapter_count.is_none()); assert!(body.custom_metadata.is_none()); // Verify all locks are false diff --git a/tests/api/series.rs b/tests/api/series.rs index e9c366a0..bcc10f51 100644 --- a/tests/api/series.rs +++ b/tests/api/series.rs @@ -2001,7 +2001,6 @@ async fn test_replace_series_metadata_success() { language: None, year: Some(2020), reading_direction: Some("ltr".to_string()), - total_book_count: None, total_volume_count: None, total_chapter_count: None, custom_metadata: Some(serde_json::json!({"tag": "value"})), @@ -2078,7 +2077,6 @@ async fn test_replace_series_metadata_clears_omitted_fields() { language: None, year: None, // Should clear year reading_direction: None, - total_book_count: None, total_volume_count: None, total_chapter_count: None, custom_metadata: None, @@ -2121,7 +2119,6 @@ async fn test_replace_series_metadata_not_found() { language: None, year: None, reading_direction: None, - total_book_count: None, total_volume_count: None, total_chapter_count: None, custom_metadata: None, @@ -2169,7 +2166,6 @@ async fn test_replace_series_metadata_without_auth() { language: None, year: None, reading_direction: None, - total_book_count: None, total_volume_count: None, total_chapter_count: None, custom_metadata: None, @@ -4937,7 +4933,7 @@ async fn test_list_series_filtered_by_completion_complete() { BookRepository::create(&db, &book, None).await.unwrap(); } - // Create a series without total_book_count (should be excluded from both filters) + // Create a series without a volume count (should be excluded from both filters) let no_count_series = SeriesRepository::create(&db, library.id, "No Count Series", None) .await .unwrap(); @@ -4988,14 +4984,14 @@ async fn test_list_series_filtered_by_completion_complete() { } #[tokio::test] -async fn test_list_series_filtered_by_completion_with_no_total_book_count() { +async fn test_list_series_filtered_by_completion_with_no_volume_count() { let (db, _temp_dir) = setup_test_db().await; let library = LibraryRepository::create(&db, "Library", "/lib", ScanningStrategy::Default) .await .unwrap(); - // Create series without total_book_count + // Create series without a volume count let series1 = SeriesRepository::create(&db, library.id, "Series Without Count", None) .await .unwrap(); @@ -5012,7 +5008,7 @@ async fn test_list_series_filtered_by_completion_with_no_total_book_count() { let token = create_admin_and_token(&db, &state).await; let app = create_test_router(state).await; - // Filter by completion = true should return empty (no series have total_book_count) + // Filter by completion = true should return empty (no series have a volume count) let request_body = SeriesListRequest { condition: Some(SeriesCondition::Completion { completion: BoolOperator::IsTrue, @@ -5028,7 +5024,7 @@ async fn test_list_series_filtered_by_completion_with_no_total_book_count() { assert_eq!( series_list.data.len(), 0, - "Series without total_book_count should not appear in complete filter" + "Series without a volume count should not appear in complete filter" ); // Filter by completion = false should also return empty @@ -5047,7 +5043,7 @@ async fn test_list_series_filtered_by_completion_with_no_total_book_count() { assert_eq!( series_list.data.len(), 0, - "Series without total_book_count should not appear in incomplete filter" + "Series without a volume count should not appear in incomplete filter" ); } @@ -6007,7 +6003,6 @@ async fn test_replace_series_metadata_with_authors() { language: None, year: None, reading_direction: None, - total_book_count: None, total_volume_count: None, total_chapter_count: None, custom_metadata: None, @@ -6076,7 +6071,6 @@ async fn test_replace_series_metadata_clears_authors_when_omitted() { language: None, year: None, reading_direction: None, - total_book_count: None, total_volume_count: None, total_chapter_count: None, custom_metadata: None, @@ -6110,7 +6104,6 @@ async fn test_replace_series_metadata_clears_authors_when_omitted() { language: None, year: None, reading_direction: None, - total_book_count: None, total_volume_count: None, total_chapter_count: None, custom_metadata: None, diff --git a/web/openapi.json b/web/openapi.json index f029ff8c..38a1e2b6 100644 --- a/web/openapi.json +++ b/web/openapi.json @@ -20397,14 +20397,6 @@ ], "description": "Series status (ongoing, ended, hiatus, abandoned, unknown)" }, - "totalBookCount": { - "type": [ - "integer", - "null" - ], - "format": "int32", - "description": "Expected total book count (for ongoing series).\n\nDEPRECATED: kept for one phase of backward-compat with API clients\npinned to the legacy field. Sets `total_volume_count` under the hood.\nRemoved in Phase 9 of the metadata-count-split plan." - }, "totalChapterCount": { "type": [ "number", @@ -23318,15 +23310,6 @@ "description": "Custom sort name for ordering", "example": "Batman Year One" }, - "totalBookCount": { - "type": [ - "integer", - "null" - ], - "format": "int32", - "description": "Expected total book count (for ongoing series).\n\nDEPRECATED: kept for one phase of backward-compat. Mirrors\n`totalVolumeCount`. Removed in Phase 9.", - "example": 4 - }, "totalChapterCount": { "type": [ "number", @@ -26338,19 +26321,6 @@ "type": "boolean", "description": "Whether title_sort is locked" }, - "totalBookCount": { - "type": [ - "integer", - "null" - ], - "format": "int32", - "description": "Expected total book count.\n\nDEPRECATED: kept for one phase of backward-compat. Mirrors\n`totalVolumeCount`. Removed in Phase 9 of the metadata-count-split plan.", - "example": 110 - }, - "totalBookCountLock": { - "type": "boolean", - "description": "Whether total_book_count is locked.\n\nDEPRECATED: kept for one phase of backward-compat. Mirrors\n`totalVolumeCountLock`. Removed in Phase 9." - }, "totalChapterCount": { "type": [ "number", @@ -26437,7 +26407,6 @@ "language", "readingDirection", "year", - "totalBookCount", "totalVolumeCount", "totalChapterCount", "genres", @@ -26523,11 +26492,6 @@ "description": "Whether the title_sort field is locked", "example": false }, - "totalBookCount": { - "type": "boolean", - "description": "Whether the total_book_count field is locked.\n\nDEPRECATED: kept for one phase of backward-compat. Mirrors\n`totalVolumeCountLock`. Removed in Phase 9.", - "example": false - }, "totalChapterCount": { "type": "boolean", "description": "Whether the total_chapter_count field is locked", @@ -28782,15 +28746,6 @@ "description": "Custom sort name for ordering", "example": "Batman Year One" }, - "totalBookCount": { - "type": [ - "integer", - "null" - ], - "format": "int32", - "description": "Expected total book count (for ongoing series).\n\nDEPRECATED: kept for one phase of backward-compat. Sets\n`totalVolumeCount` under the hood. Removed in Phase 9 of the\nmetadata-count-split plan.", - "example": 4 - }, "totalChapterCount": { "type": [ "number", @@ -30749,14 +30704,6 @@ "type": "string", "description": "Title of the recommended series/book" }, - "totalBookCount": { - "type": [ - "integer", - "null" - ], - "format": "int32", - "description": "Total expected number of books/volumes in the series.\n\nDEPRECATED: kept for one phase of backward-compat. Mirrors\n`totalVolumeCount`. Removed in Phase 9 of metadata-count-split." - }, "totalChapterCount": { "type": [ "number", @@ -31344,15 +31291,6 @@ "description": "Custom sort name for ordering (e.g., \"Batman Year One\" instead of \"The Batman Year One\")", "example": "Batman Year One" }, - "totalBookCount": { - "type": [ - "integer", - "null" - ], - "format": "int32", - "description": "Expected total book count (for ongoing series).\n\nDEPRECATED: kept for one phase of backward-compat. Mirrors\n`totalVolumeCount`. Use `totalVolumeCount` and/or `totalChapterCount`\ngoing forward; this field is removed in Phase 9 of the\nmetadata-count-split plan.", - "example": 4 - }, "totalChapterCount": { "type": [ "number", @@ -32644,15 +32582,6 @@ "description": "Custom sort name for ordering", "example": "Batman Year One" }, - "totalBookCount": { - "type": [ - "integer", - "null" - ], - "format": "int32", - "description": "Expected total book count (for ongoing series).\n\nDEPRECATED: kept for one phase of backward-compat. Mirrors\n`totalVolumeCount`. Removed in Phase 9.", - "example": 4 - }, "totalChapterCount": { "type": [ "number", @@ -32831,15 +32760,6 @@ "description": "Custom sort name for ordering", "example": "Batman Year One" }, - "totalBookCount": { - "type": [ - "integer", - "null" - ], - "format": "int32", - "description": "Expected total book count (for ongoing series).\n\nDEPRECATED: kept for one phase of backward-compat. Mirrors\n`totalVolumeCount`. Removed in Phase 9 of the metadata-count-split plan.", - "example": 4 - }, "totalChapterCount": { "type": [ "number", @@ -35660,14 +35580,6 @@ "description": "Whether to lock the title_sort field", "example": false }, - "totalBookCount": { - "type": [ - "boolean", - "null" - ], - "description": "Whether to lock the total_book_count field.\n\nDEPRECATED: kept for one phase of backward-compat. Sets\n`totalVolumeCountLock` under the hood. Removed in Phase 9.", - "example": false - }, "totalChapterCount": { "type": [ "boolean", diff --git a/web/src/types/api.generated.ts b/web/src/types/api.generated.ts index 534705e1..efa7a10b 100644 --- a/web/src/types/api.generated.ts +++ b/web/src/types/api.generated.ts @@ -8261,15 +8261,6 @@ export interface components { seriesIds: string[]; /** @description Series status (ongoing, ended, hiatus, abandoned, unknown) */ status?: string | null; - /** - * Format: int32 - * @description Expected total book count (for ongoing series). - * - * DEPRECATED: kept for one phase of backward-compat with API clients - * pinned to the legacy field. Sets `total_volume_count` under the hood. - * Removed in Phase 9 of the metadata-count-split plan. - */ - totalBookCount?: number | null; /** * Format: float * @description Expected total chapter count (for chapter-organized series). May be fractional. @@ -9676,15 +9667,6 @@ export interface components { * @example Batman Year One */ titleSort?: string | null; - /** - * Format: int32 - * @description Expected total book count (for ongoing series). - * - * DEPRECATED: kept for one phase of backward-compat. Mirrors - * `totalVolumeCount`. Removed in Phase 9. - * @example 4 - */ - totalBookCount?: number | null; /** * Format: float * @description Expected total chapter count (for chapter-organized series). May be fractional. @@ -11203,22 +11185,6 @@ export interface components { titleSort?: string | null; /** @description Whether title_sort is locked */ titleSortLock?: boolean; - /** - * Format: int32 - * @description Expected total book count. - * - * DEPRECATED: kept for one phase of backward-compat. Mirrors - * `totalVolumeCount`. Removed in Phase 9 of the metadata-count-split plan. - * @example 110 - */ - totalBookCount?: number | null; - /** - * @description Whether total_book_count is locked. - * - * DEPRECATED: kept for one phase of backward-compat. Mirrors - * `totalVolumeCountLock`. Removed in Phase 9. - */ - totalBookCountLock?: boolean; /** * Format: float * @description Expected total chapter count (may be fractional) @@ -11335,14 +11301,6 @@ export interface components { * @example false */ titleSort: boolean; - /** - * @description Whether the total_book_count field is locked. - * - * DEPRECATED: kept for one phase of backward-compat. Mirrors - * `totalVolumeCountLock`. Removed in Phase 9. - * @example false - */ - totalBookCount: boolean; /** * @description Whether the total_chapter_count field is locked * @example false @@ -12733,16 +12691,6 @@ export interface components { * @example Batman Year One */ titleSort?: string | null; - /** - * Format: int32 - * @description Expected total book count (for ongoing series). - * - * DEPRECATED: kept for one phase of backward-compat. Sets - * `totalVolumeCount` under the hood. Removed in Phase 9 of the - * metadata-count-split plan. - * @example 4 - */ - totalBookCount?: number | null; /** * Format: float * @description Expected total chapter count (for chapter-organized series). May be fractional. @@ -13783,14 +13731,6 @@ export interface components { tags?: components["schemas"]["RecommendationTagDto"][] | null; /** @description Title of the recommended series/book */ title: string; - /** - * Format: int32 - * @description Total expected number of books/volumes in the series. - * - * DEPRECATED: kept for one phase of backward-compat. Mirrors - * `totalVolumeCount`. Removed in Phase 9 of metadata-count-split. - */ - totalBookCount?: number | null; /** * Format: float * @description Total expected number of chapters in the series. May be fractional. @@ -14139,17 +14079,6 @@ export interface components { * @example Batman Year One */ titleSort?: string | null; - /** - * Format: int32 - * @description Expected total book count (for ongoing series). - * - * DEPRECATED: kept for one phase of backward-compat. Mirrors - * `totalVolumeCount`. Use `totalVolumeCount` and/or `totalChapterCount` - * going forward; this field is removed in Phase 9 of the - * metadata-count-split plan. - * @example 4 - */ - totalBookCount?: number | null; /** * Format: float * @description Expected total chapter count (for chapter-organized series). May be fractional. @@ -14869,15 +14798,6 @@ export interface components { * @example Batman Year One */ titleSort?: string | null; - /** - * Format: int32 - * @description Expected total book count (for ongoing series). - * - * DEPRECATED: kept for one phase of backward-compat. Mirrors - * `totalVolumeCount`. Removed in Phase 9. - * @example 4 - */ - totalBookCount?: number | null; /** * Format: float * @description Expected total chapter count (for chapter-organized series). May be fractional. @@ -14983,15 +14903,6 @@ export interface components { * @example Batman Year One */ titleSort?: string | null; - /** - * Format: int32 - * @description Expected total book count (for ongoing series). - * - * DEPRECATED: kept for one phase of backward-compat. Mirrors - * `totalVolumeCount`. Removed in Phase 9 of the metadata-count-split plan. - * @example 4 - */ - totalBookCount?: number | null; /** * Format: float * @description Expected total chapter count (for chapter-organized series). May be fractional. @@ -16412,14 +16323,6 @@ export interface components { * @example false */ titleSort?: boolean | null; - /** - * @description Whether to lock the total_book_count field. - * - * DEPRECATED: kept for one phase of backward-compat. Sets - * `totalVolumeCountLock` under the hood. Removed in Phase 9. - * @example false - */ - totalBookCount?: boolean | null; /** * @description Whether to lock the total_chapter_count field * @example false From 6234e7701e4f37b19dac0dcf4873cecba5682e44 Mon Sep 17 00:00:00 2001 From: Sylvain Cau Date: Fri, 1 May 2026 16:50:32 -0700 Subject: [PATCH 09/19] refactor(komga): rename DTO count field to total_volume_count, preserve totalBookCount wire format Rename the internal field on `KomgaSeriesMetadataDto` from `total_book_count` to `total_volume_count` to match the canonical source field on `series_metadata`. Keep Komga's wire format unchanged with explicit `#[serde(rename = "totalBookCount" / "totalBookCountLock")]` so Komga clients (Komic, Mihon, etc.) continue to see the field names they expect. The struct-level `rename_all = "camelCase"` would otherwise emit `totalVolumeCount`. Komga upstream has no chapter-count field, so `total_chapter_count` is intentionally not surfaced on the Komga DTO; chapter data is exposed only via the native v1 API. Update the handler in routes/komga/handlers/series.rs to populate the renamed fields from `series_metadata.total_volume_count` (and lock). Add tests covering the wire shape end-to-end: a Komic-style PUT-payload roundtrip on the DTO, an Option-skip assertion when the count is None, and an integration test against the live handler that asserts `metadata.totalBookCount` is populated from `total_volume_count`, no internal field name leaks, and no chapter-count field is invented. Regenerate web/openapi.json, docs/api/openapi.json, and web/src/types/api.generated.ts to match. --- docs/api/openapi.json | 4 +- src/api/routes/komga/dto/series.rs | 73 +++++++++++++++++++++---- src/api/routes/komga/handlers/series.rs | 4 +- tests/api/komga.rs | 49 +++++++++++++++++ web/openapi.json | 4 +- web/src/types/api.generated.ts | 10 +++- 6 files changed, 126 insertions(+), 18 deletions(-) diff --git a/docs/api/openapi.json b/docs/api/openapi.json index 38a1e2b6..33d0124a 100644 --- a/docs/api/openapi.json +++ b/docs/api/openapi.json @@ -25440,11 +25440,11 @@ "null" ], "format": "int32", - "description": "Total book count (expected)" + "description": "Total book count (expected). Komga's wire field is `totalBookCount`,\nwhich is volume-shaped semantically; we populate it from Codex's\n`series_metadata.total_volume_count`. Keep the serde rename so Komga\nclients (Komic, Mihon, etc.) see the field name they expect." }, "totalBookCountLock": { "type": "boolean", - "description": "Whether total_book_count is locked" + "description": "Whether total_volume_count is locked. Wire name stays `totalBookCountLock`\nto match Komga's schema." } } }, diff --git a/src/api/routes/komga/dto/series.rs b/src/api/routes/komga/dto/series.rs index 4c0a03bd..bbf343cf 100644 --- a/src/api/routes/komga/dto/series.rs +++ b/src/api/routes/komga/dto/series.rs @@ -86,12 +86,16 @@ pub struct KomgaSeriesMetadataDto { /// Whether tags are locked #[serde(default)] pub tags_lock: bool, - /// Total book count (expected) - #[serde(skip_serializing_if = "Option::is_none")] - pub total_book_count: Option, - /// Whether total_book_count is locked - #[serde(default)] - pub total_book_count_lock: bool, + /// Total book count (expected). Komga's wire field is `totalBookCount`, + /// which is volume-shaped semantically; we populate it from Codex's + /// `series_metadata.total_volume_count`. Keep the serde rename so Komga + /// clients (Komic, Mihon, etc.) see the field name they expect. + #[serde(rename = "totalBookCount", skip_serializing_if = "Option::is_none")] + pub total_volume_count: Option, + /// Whether total_volume_count is locked. Wire name stays `totalBookCountLock` + /// to match Komga's schema. + #[serde(rename = "totalBookCountLock", default)] + pub total_volume_count_lock: bool, /// Sharing labels #[serde(default)] pub sharing_labels: Vec, @@ -140,8 +144,8 @@ impl Default for KomgaSeriesMetadataDto { genres_lock: false, tags: Vec::new(), tags_lock: false, - total_book_count: None, - total_book_count_lock: false, + total_volume_count: None, + total_volume_count_lock: false, sharing_labels: Vec::new(), sharing_labels_lock: false, links: Vec::new(), @@ -388,7 +392,11 @@ mod tests { #[test] fn test_series_metadata_camel_case() { - let metadata = KomgaSeriesMetadataDto::default(); + let metadata = KomgaSeriesMetadataDto { + total_volume_count: Some(14), + total_volume_count_lock: true, + ..Default::default() + }; let json = serde_json::to_string(&metadata).unwrap(); // Verify camelCase field names @@ -400,12 +408,57 @@ mod tests { assert!(json.contains("\"publisherLock\"")); assert!(json.contains("\"ageRating\"") || !json.contains("\"age_rating\"")); assert!(json.contains("\"genresLock\"")); - assert!(json.contains("\"totalBookCount\"") || !json.contains("\"total_book_count\"")); + // Komga's wire field name for the volume count must remain `totalBookCount` + // (and `totalBookCountLock`) regardless of the internal Rust field name. + assert!(json.contains("\"totalBookCount\":14")); + assert!(json.contains("\"totalBookCountLock\":true")); + assert!(!json.contains("\"totalVolumeCount\"")); + assert!(!json.contains("\"total_volume_count\"")); assert!(json.contains("\"sharingLabels\"")); assert!(json.contains("\"alternateTitles\"")); assert!(json.contains("\"lastModified\"")); } + #[test] + fn test_series_metadata_total_book_count_roundtrip() { + // Komic / Mihon send PUT requests with `totalBookCount` and + // `totalBookCountLock`; ensure we round-trip cleanly through the + // internally-renamed field. + let json = r#"{ + "status": "ONGOING", + "title": "Test", + "titleSort": "Test", + "publisher": "", + "language": "", + "genres": [], + "tags": [], + "totalBookCount": 14, + "totalBookCountLock": true, + "sharingLabels": [], + "links": [], + "alternateTitles": [], + "created": "2026-01-01T00:00:00Z", + "lastModified": "2026-01-01T00:00:00Z" + }"#; + let parsed: KomgaSeriesMetadataDto = serde_json::from_str(json).unwrap(); + assert_eq!(parsed.total_volume_count, Some(14)); + assert!(parsed.total_volume_count_lock); + + let reserialized = serde_json::to_string(&parsed).unwrap(); + assert!(reserialized.contains("\"totalBookCount\":14")); + assert!(reserialized.contains("\"totalBookCountLock\":true")); + } + + #[test] + fn test_series_metadata_omits_total_book_count_when_none() { + let metadata = KomgaSeriesMetadataDto::default(); + let json = serde_json::to_string(&metadata).unwrap(); + // None must skip serialization (Komga clients tolerate missing field). + assert!(!json.contains("\"totalBookCount\"")); + // Lock field is a plain bool, so it always serializes. + assert!(json.contains("\"totalBookCountLock\":false")); + } + #[test] fn test_reading_direction_conversion() { assert_eq!( diff --git a/src/api/routes/komga/handlers/series.rs b/src/api/routes/komga/handlers/series.rs index d29a7597..784bf6cf 100644 --- a/src/api/routes/komga/handlers/series.rs +++ b/src/api/routes/komga/handlers/series.rs @@ -833,8 +833,8 @@ async fn build_series_dto( // map our `total_volume_count` (and its lock) to it. If/when // Komga adds a chapter-count field upstream, surface // `total_chapter_count` there too. - total_book_count: m.total_volume_count, - total_book_count_lock: m.total_volume_count_lock, + total_volume_count: m.total_volume_count, + total_volume_count_lock: m.total_volume_count_lock, sharing_labels: Vec::new(), sharing_labels_lock: false, links, diff --git a/tests/api/komga.rs b/tests/api/komga.rs index 4d464805..176e61ac 100644 --- a/tests/api/komga.rs +++ b/tests/api/komga.rs @@ -3333,6 +3333,55 @@ async fn test_komga_list_series_returns_metadata_fields() { assert_eq!(dto.metadata.tags[0], "shounen"); } +/// Komga's wire format for the volume count is `totalBookCount` (volume-shaped +/// semantically). We populate it from Codex's `total_volume_count` so that +/// Komga clients (Komic, Mihon, etc.) see the field they expect. Verifies the +/// wire shape end-to-end against an actual handler response. +#[tokio::test] +async fn test_komga_series_total_book_count_maps_from_volume_count() { + let (db, temp_dir) = setup_test_db().await; + + let library = LibraryRepository::create(&db, "Manga", "/manga", ScanningStrategy::Default) + .await + .unwrap(); + let series = SeriesRepository::create(&db, library.id, "One Piece", None) + .await + .unwrap(); + + SeriesMetadataRepository::update_total_volume_count(&db, series.id, Some(108)) + .await + .unwrap(); + SeriesMetadataRepository::set_lock(&db, series.id, "total_volume_count", true) + .await + .unwrap(); + // Chapter count must NOT leak into Komga's wire format (Komga's schema + // doesn't have a chapter-count field). + SeriesMetadataRepository::update_total_chapter_count(&db, series.id, Some(1100.5)) + .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/series/{}", series.id); + let request = get_request_with_auth(&uri, &token); + let (status, body) = make_request(app, request).await; + + assert_eq!(status, StatusCode::OK); + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); + let metadata = &json["metadata"]; + + assert_eq!(metadata["totalBookCount"], 108); + assert_eq!(metadata["totalBookCountLock"], true); + // Reverse direction: no Codex-internal field name should leak. + assert!(metadata.get("totalVolumeCount").is_none()); + assert!(metadata.get("total_volume_count").is_none()); + // No chapter-count field is invented for Komga clients. + assert!(metadata.get("totalChapterCount").is_none()); + assert!(metadata.get("chaptersCount").is_none()); +} + // ============================================================================ // Book Metadata Fields Tests (authors, summary, release_date, tags) // ============================================================================ diff --git a/web/openapi.json b/web/openapi.json index 38a1e2b6..33d0124a 100644 --- a/web/openapi.json +++ b/web/openapi.json @@ -25440,11 +25440,11 @@ "null" ], "format": "int32", - "description": "Total book count (expected)" + "description": "Total book count (expected). Komga's wire field is `totalBookCount`,\nwhich is volume-shaped semantically; we populate it from Codex's\n`series_metadata.total_volume_count`. Keep the serde rename so Komga\nclients (Komic, Mihon, etc.) see the field name they expect." }, "totalBookCountLock": { "type": "boolean", - "description": "Whether total_book_count is locked" + "description": "Whether total_volume_count is locked. Wire name stays `totalBookCountLock`\nto match Komga's schema." } } }, diff --git a/web/src/types/api.generated.ts b/web/src/types/api.generated.ts index efa7a10b..2a7e0b9d 100644 --- a/web/src/types/api.generated.ts +++ b/web/src/types/api.generated.ts @@ -10725,10 +10725,16 @@ export interface components { titleSortLock?: boolean; /** * Format: int32 - * @description Total book count (expected) + * @description Total book count (expected). Komga's wire field is `totalBookCount`, + * which is volume-shaped semantically; we populate it from Codex's + * `series_metadata.total_volume_count`. Keep the serde rename so Komga + * clients (Komic, Mihon, etc.) see the field name they expect. */ totalBookCount?: number | null; - /** @description Whether total_book_count is locked */ + /** + * @description Whether total_volume_count is locked. Wire name stays `totalBookCountLock` + * to match Komga's schema. + */ totalBookCountLock?: boolean; }; /** From 8e48f1d1bcf9b51564bed6ecf8012f391d2464d2 Mon Sep 17 00:00:00 2001 From: Sylvain Cau Date: Fri, 1 May 2026 17:49:46 -0700 Subject: [PATCH 10/19] refactor(metadata): drop legacy total_book_count from schema, protocol, and DTOs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hard-remove the overloaded total_book_count field everywhere it still exists outside the Komga compatibility layer, completing the migration to total_volume_count + total_chapter_count. Schema: new migration drops series_metadata.total_book_count and total_book_count_lock; down() re-adds them as nullable column + non-null default-false bool with no data restore. Plugin protocol bumped to 1.2 (breaking minor): PluginSeriesMetadata and UserLibraryEntry lose total_book_count on the wire, and the MetadataWriteTotalBookCount permission is removed from the enum, parser, display, and permission-set builders. Older plugins that still emit the legacy field round-trip silently — serde drops the unknown value with no fallback path. MetadataApplier loses its legacy routing block and the per-plugin deprecation-warning helper. The plugin-actions preview shim that or'd total_book_count into the proposed volume count is gone. SeriesMetadataRepository::update_total_book_count() and the "total_book_count" branches in set_lock / is_field_locked are removed. Read-site sweep: Recommendation, MetadataContext (preprocessing), the recommendations handler fallback, the user-library push entries, and the v1 plugin permission allowlist are now split-only. SDK TypeScript types (protocol.ts, recommendations.ts) lose the deprecated field from PluginSeriesMetadata, UserLibraryEntry, and Recommendation. Plugins: metadata-echo, metadata-mangabaka, and recommendations-anilist no longer mirror the volume value into a legacy field; matching test assertions are pruned. Frontend: MetadataPreview labels swap "Total Books" for separate "Total Volumes" + "Total Chapters" entries; MetadataSearchModal reads totalVolumeCount; RecommendationCard renders separate vol/ch badges; templateUtils, exampleTemplates Handlebars samples, and the MSW mock handlers carry the split fields and split locks. The web permission union/lookup adds metadata:write:total_volume_count and metadata:write:total_chapter_count, dropping the legacy permission. The Komga API DTO keeps its serde-renamed totalBookCount wire field intact — Komga clients (Komic, Mihon, etc.) still see the field name they expect. Migration test coverage: a new SQLite test runs the count-split + drop pair step-by-step and asserts the legacy columns disappear while the split-count columns survive. Three integration tests in metadata_apply that exercised the (now-gone) backward-compat fallback are removed along with their helper. --- migration/src/lib.rs | 4 + .../src/m20260502_000068_drop_book_count.rs | 73 ++++++++ plugins/metadata-echo/src/index.ts | 2 +- .../metadata-mangabaka/src/mappers.test.ts | 4 - plugins/metadata-mangabaka/src/mappers.ts | 4 - .../recommendations-anilist/src/index.test.ts | 5 - plugins/recommendations-anilist/src/index.ts | 3 - plugins/sdk-typescript/src/types/protocol.ts | 8 - .../src/types/recommendations.ts | 14 -- src/api/routes/v1/dto/plugins.rs | 1 - src/api/routes/v1/dto/recommendations.rs | 3 +- src/api/routes/v1/handlers/plugin_actions.rs | 11 +- src/api/routes/v1/handlers/recommendations.rs | 7 +- src/api/routes/v1/handlers/series.rs | 2 - src/db/entities/plugins.rs | 23 +-- src/db/entities/series_metadata.rs | 2 - src/db/repositories/series.rs | 2 - src/db/repositories/series_metadata.rs | 28 --- src/services/metadata/apply.rs | 40 +---- .../metadata/preprocessing/context.rs | 33 +--- src/services/plugin/library.rs | 4 - src/services/plugin/protocol.rs | 57 ++---- src/services/plugin/recommendations.rs | 17 +- tests/api/plugins.rs | 4 +- tests/db/migrations.rs | 43 ++++- tests/services/metadata_apply.rs | 170 ------------------ web/src/api/plugins.ts | 11 +- .../components/metadata/MetadataPreview.tsx | 3 +- .../metadata/MetadataSearchModal.tsx | 2 +- .../RecommendationCard.test.tsx | 4 +- .../recommendations/RecommendationCard.tsx | 17 +- .../series/CustomMetadataDisplay.test.tsx | 42 +++-- web/src/components/series/seriesCounts.ts | 4 +- web/src/data/exampleTemplates.ts | 13 +- web/src/mocks/handlers/recommendations.ts | 28 +-- web/src/mocks/handlers/series.ts | 27 ++- web/src/utils/templateUtils.test.ts | 41 +++-- web/src/utils/templateUtils.ts | 27 ++- 38 files changed, 298 insertions(+), 485 deletions(-) create mode 100644 migration/src/m20260502_000068_drop_book_count.rs diff --git a/migration/src/lib.rs b/migration/src/lib.rs index d7582054..32ab859d 100644 --- a/migration/src/lib.rs +++ b/migration/src/lib.rs @@ -137,6 +137,8 @@ mod m20260410_000066_add_export_type; // Split series_metadata.total_book_count into total_volume_count and total_chapter_count mod m20260502_000067_split_book_count; +// Drop legacy series_metadata.total_book_count and lock columns (Phase 9 hard removal) +mod m20260502_000068_drop_book_count; pub struct Migrator; @@ -246,6 +248,8 @@ impl MigratorTrait for Migrator { Box::new(m20260410_000066_add_export_type::Migration), // Split total_book_count into total_volume_count and total_chapter_count Box::new(m20260502_000067_split_book_count::Migration), + // Drop legacy total_book_count column and lock (Phase 9 hard removal) + Box::new(m20260502_000068_drop_book_count::Migration), ] } } diff --git a/migration/src/m20260502_000068_drop_book_count.rs b/migration/src/m20260502_000068_drop_book_count.rs new file mode 100644 index 00000000..01271afe --- /dev/null +++ b/migration/src/m20260502_000068_drop_book_count.rs @@ -0,0 +1,73 @@ +//! Drop the legacy `series_metadata.total_book_count` and `total_book_count_lock` columns. +//! +//! Phase 9 of the metadata-count-split plan: hard removal. The new +//! `total_volume_count` / `total_chapter_count` columns and their locks are now +//! the sole source of truth, written by `MetadataApplier` and surfaced through +//! every read site. The legacy column is no longer read or written anywhere in +//! the codebase, so we drop it to make any leftover reference fail at compile +//! or runtime. +//! +//! Down: re-adds the legacy columns as nullable (value) and not-null+default +//! (lock). No data restore is possible (volume data was already copied across +//! in migration 067 but is not symmetric to a chapter-organized state). Down +//! exists for symmetry and dev-environment reset; production rollback requires +//! restoring from a pre-Phase-9 backup. + +use sea_orm_migration::prelude::*; + +use crate::m20260103_000006_create_series_metadata::SeriesMetadata; + +#[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(SeriesMetadata::Table) + .drop_column(Alias::new("total_book_count_lock")) + .to_owned(), + ) + .await?; + + manager + .alter_table( + Table::alter() + .table(SeriesMetadata::Table) + .drop_column(Alias::new("total_book_count")) + .to_owned(), + ) + .await?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .alter_table( + Table::alter() + .table(SeriesMetadata::Table) + .add_column(ColumnDef::new(Alias::new("total_book_count")).integer()) + .to_owned(), + ) + .await?; + + manager + .alter_table( + Table::alter() + .table(SeriesMetadata::Table) + .add_column( + ColumnDef::new(Alias::new("total_book_count_lock")) + .boolean() + .not_null() + .default(false), + ) + .to_owned(), + ) + .await?; + + Ok(()) + } +} diff --git a/plugins/metadata-echo/src/index.ts b/plugins/metadata-echo/src/index.ts index a0eb7c40..d9bf2db8 100644 --- a/plugins/metadata-echo/src/index.ts +++ b/plugins/metadata-echo/src/index.ts @@ -84,7 +84,7 @@ const provider: MetadataProvider = { year: 2024, // Extended metadata fields - totalBookCount: 10, + totalVolumeCount: 10, language: "en", ageRating: 13, readingDirection: "ltr", diff --git a/plugins/metadata-mangabaka/src/mappers.test.ts b/plugins/metadata-mangabaka/src/mappers.test.ts index 6694cb1c..ed3a1841 100644 --- a/plugins/metadata-mangabaka/src/mappers.test.ts +++ b/plugins/metadata-mangabaka/src/mappers.test.ts @@ -423,9 +423,6 @@ describe("mappers", () => { expect(result.totalVolumeCount).toBe(108); expect(result.totalChapterCount).toBe(1138); - // Legacy totalBookCount kept populated mirroring the volume count for - // one phase of backward-compat. - expect(result.totalBookCount).toBe(108); }); it("should populate fractional totalChapterCount", () => { @@ -472,7 +469,6 @@ describe("mappers", () => { expect(result.totalVolumeCount).toBeUndefined(); expect(result.totalChapterCount).toBeUndefined(); - expect(result.totalBookCount).toBeUndefined(); }); it("should treat non-numeric or non-positive count strings as undefined", () => { diff --git a/plugins/metadata-mangabaka/src/mappers.ts b/plugins/metadata-mangabaka/src/mappers.ts index 45b6c06e..ca5dd46a 100644 --- a/plugins/metadata-mangabaka/src/mappers.ts +++ b/plugins/metadata-mangabaka/src/mappers.ts @@ -364,10 +364,6 @@ export function mapSeriesMetadata(series: MbSeries): PluginSeriesMetadata { year: series.year ?? undefined, // Extended metadata publisher, - // Legacy field kept populated for backward-compat with older Codex versions - // that don't yet read totalVolumeCount; mirrors the volume value (most - // metadata in the wild is volume-shaped). - totalBookCount: totalVolumeCount, totalVolumeCount, totalChapterCount, ageRating: mapContentRating(series.content_rating), diff --git a/plugins/recommendations-anilist/src/index.test.ts b/plugins/recommendations-anilist/src/index.test.ts index 7d47ae31..42241f8b 100644 --- a/plugins/recommendations-anilist/src/index.test.ts +++ b/plugins/recommendations-anilist/src/index.test.ts @@ -266,15 +266,12 @@ describe("convertRecommendations", () => { const nodes = [makeNode({ id: 1, rating: 50, volumes: 27 })]; const results = convertRecommendations(nodes, "Test", new Set(), new Set()); expect(results[0].totalVolumeCount).toBe(27); - // Legacy totalBookCount mirrors the volume value for backward-compat - expect(results[0].totalBookCount).toBe(27); }); it("leaves totalVolumeCount undefined when volumes is null", () => { const nodes = [makeNode({ id: 1, rating: 50, volumes: null })]; const results = convertRecommendations(nodes, "Test", new Set(), new Set()); expect(results[0].totalVolumeCount).toBeUndefined(); - expect(results[0].totalBookCount).toBeUndefined(); }); it("includes totalChapterCount from chapters", () => { @@ -294,7 +291,6 @@ describe("convertRecommendations", () => { const results = convertRecommendations(nodes, "Test", new Set(), new Set()); expect(results[0].totalVolumeCount).toBe(14); expect(results[0].totalChapterCount).toBe(109); - expect(results[0].totalBookCount).toBe(14); }); it("treats zero or negative volumes/chapters as undefined", () => { @@ -302,7 +298,6 @@ describe("convertRecommendations", () => { const results = convertRecommendations(nodes, "Test", new Set(), new Set()); expect(results[0].totalVolumeCount).toBeUndefined(); expect(results[0].totalChapterCount).toBeUndefined(); - expect(results[0].totalBookCount).toBeUndefined(); }); it("includes rating from AniList averageScore", () => { diff --git a/plugins/recommendations-anilist/src/index.ts b/plugins/recommendations-anilist/src/index.ts index a8c28e20..5cb64b07 100644 --- a/plugins/recommendations-anilist/src/index.ts +++ b/plugins/recommendations-anilist/src/index.ts @@ -302,9 +302,6 @@ export function convertRecommendations( format: media.format ?? undefined, countryOfOrigin: media.countryOfOrigin ?? undefined, startYear: media.startDate?.year ?? undefined, - // Legacy field mirrors the volume count so older Codex versions still - // see a value; new field is the authoritative one going forward. - totalBookCount: totalVolumeCount, totalVolumeCount, totalChapterCount, rating: media.averageScore ?? undefined, diff --git a/plugins/sdk-typescript/src/types/protocol.ts b/plugins/sdk-typescript/src/types/protocol.ts index b646e317..706015d2 100644 --- a/plugins/sdk-typescript/src/types/protocol.ts +++ b/plugins/sdk-typescript/src/types/protocol.ts @@ -117,14 +117,6 @@ export interface PluginSeriesMetadata { year?: number; // Extended metadata - /** - * Expected total number of books in the series. - * - * @deprecated Use `totalVolumeCount` and/or `totalChapterCount` instead. - * Kept for one phase of backward-compat with older plugins; will be removed - * in a future protocol version. - */ - totalBookCount?: number; /** * Expected total number of volumes in the series, when known. * Use this for volume-organized libraries. diff --git a/plugins/sdk-typescript/src/types/recommendations.ts b/plugins/sdk-typescript/src/types/recommendations.ts index 6c824e4f..565cbf62 100644 --- a/plugins/sdk-typescript/src/types/recommendations.ts +++ b/plugins/sdk-typescript/src/types/recommendations.ts @@ -39,13 +39,6 @@ export interface UserLibraryEntry { genres: string[]; /** Tags */ tags: string[]; - /** - * Total number of books in the series. - * - * @deprecated Use `totalVolumeCount` and/or `totalChapterCount` instead. - * Kept for one phase of backward-compat with older plugins. - */ - totalBookCount?: number; /** Expected total number of volumes in the series, when known */ totalVolumeCount?: number; /** Expected total number of chapters in the series, when known. May be fractional. */ @@ -118,13 +111,6 @@ export interface Recommendation { countryOfOrigin?: string; /** Year the series started */ startYear?: number; - /** - * Total expected number of books/volumes in the series. - * - * @deprecated Use `totalVolumeCount` and/or `totalChapterCount` instead. - * Kept for one phase of backward-compat with older plugins. - */ - totalBookCount?: number; /** Total expected number of volumes in the series, when known */ totalVolumeCount?: number; /** Total expected number of chapters in the series, when known. May be fractional. */ diff --git a/src/api/routes/v1/dto/plugins.rs b/src/api/routes/v1/dto/plugins.rs index 9e5dc392..367b5f6c 100644 --- a/src/api/routes/v1/dto/plugins.rs +++ b/src/api/routes/v1/dto/plugins.rs @@ -940,7 +940,6 @@ pub fn available_permissions() -> Vec<&'static str> { "metadata:write:age_rating", "metadata:write:language", "metadata:write:reading_direction", - "metadata:write:total_book_count", "metadata:write:total_volume_count", "metadata:write:total_chapter_count", // Book-specific write permissions diff --git a/src/api/routes/v1/dto/recommendations.rs b/src/api/routes/v1/dto/recommendations.rs index 0bef7293..c68463f5 100644 --- a/src/api/routes/v1/dto/recommendations.rs +++ b/src/api/routes/v1/dto/recommendations.rs @@ -172,7 +172,8 @@ mod tests { assert!(!obj.contains_key("basedOn")); assert!(!obj.contains_key("codexSeriesId")); assert!(!obj.contains_key("status")); - assert!(!obj.contains_key("totalBookCount")); + assert!(!obj.contains_key("totalVolumeCount")); + assert!(!obj.contains_key("totalChapterCount")); assert!(!obj.contains_key("rating")); assert!(!obj.contains_key("popularity")); } diff --git a/src/api/routes/v1/handlers/plugin_actions.rs b/src/api/routes/v1/handlers/plugin_actions.rs index a3055a37..ffb7f324 100644 --- a/src/api/routes/v1/handlers/plugin_actions.rs +++ b/src/api/routes/v1/handlers/plugin_actions.rs @@ -1080,19 +1080,14 @@ pub async fn preview_series_metadata( )); // Total Volume Count. - // - // Backward-compat: a plugin that still sends only the legacy - // `total_book_count` is treated as if it sent `total_volume_count`. The - // legacy compatibility shim is removed in Phase 9. - let proposed_volume_count = plugin_metadata - .total_volume_count - .or(plugin_metadata.total_book_count); fields.push(build_field_preview( "totalVolumeCount", current_metadata .as_ref() .and_then(|m| m.total_volume_count.map(|v| serde_json::json!(v))), - proposed_volume_count.map(|v| serde_json::json!(v)), + plugin_metadata + .total_volume_count + .map(|v| serde_json::json!(v)), current_metadata .as_ref() .map(|m| m.total_volume_count_lock) diff --git a/src/api/routes/v1/handlers/recommendations.rs b/src/api/routes/v1/handlers/recommendations.rs index f4314445..66aaedbc 100644 --- a/src/api/routes/v1/handlers/recommendations.rs +++ b/src/api/routes/v1/handlers/recommendations.rs @@ -384,7 +384,7 @@ fn to_recommendation_dto( format: r.format, country_of_origin: r.country_of_origin, start_year: r.start_year, - total_volume_count: r.total_volume_count.or(r.total_book_count), + total_volume_count: r.total_volume_count, total_chapter_count: r.total_chapter_count, rating: r.rating, popularity: r.popularity, @@ -554,7 +554,6 @@ mod tests { format: Some("MANGA".to_string()), country_of_origin: Some("JP".to_string()), start_year: Some(2005), - total_book_count: Some(27), total_volume_count: Some(27), total_chapter_count: None, rating: Some(90), @@ -610,7 +609,6 @@ mod tests { format: None, country_of_origin: None, start_year: None, - total_book_count: None, total_volume_count: None, total_chapter_count: None, rating: None, @@ -660,7 +658,6 @@ mod tests { format: Some("MANGA".to_string()), country_of_origin: Some("JP".to_string()), start_year: Some(2005), - total_book_count: Some(30), total_volume_count: Some(30), total_chapter_count: None, rating: Some(88), @@ -683,7 +680,6 @@ mod tests { format: None, country_of_origin: None, start_year: None, - total_book_count: None, total_volume_count: None, total_chapter_count: None, rating: None, @@ -779,7 +775,6 @@ mod tests { format: None, country_of_origin: None, start_year: None, - total_book_count: None, total_volume_count: None, total_chapter_count: None, rating: None, diff --git a/src/api/routes/v1/handlers/series.rs b/src/api/routes/v1/handlers/series.rs index b687f890..f884b8c3 100644 --- a/src/api/routes/v1/handlers/series.rs +++ b/src/api/routes/v1/handlers/series.rs @@ -767,12 +767,10 @@ pub async fn patch_series( language: Set(None), reading_direction: Set(None), year: Set(None), - total_book_count: Set(None), total_volume_count: Set(None), total_chapter_count: Set(None), custom_metadata: Set(None), authors_json: Set(None), - total_book_count_lock: Set(false), total_volume_count_lock: Set(false), total_chapter_count_lock: Set(false), title_lock: Set(true), // Auto-lock when user edits diff --git a/src/db/entities/plugins.rs b/src/db/entities/plugins.rs index 11fab502..0b0f76a8 100644 --- a/src/db/entities/plugins.rs +++ b/src/db/entities/plugins.rs @@ -378,13 +378,6 @@ pub enum PluginPermission { /// Update reading direction #[serde(rename = "metadata:write:reading_direction")] MetadataWriteReadingDirection, - /// Update total book count. - /// - /// DEPRECATED: kept for one phase of backward-compat. Removed in Phase 9 of the - /// metadata-count-split plan. New plugins should request - /// `metadata:write:total_volume_count` and/or `metadata:write:total_chapter_count`. - #[serde(rename = "metadata:write:total_book_count")] - MetadataWriteTotalBookCount, /// Update total volume count #[serde(rename = "metadata:write:total_volume_count")] MetadataWriteTotalVolumeCount, @@ -467,7 +460,6 @@ impl PluginPermission { PluginPermission::MetadataWriteAgeRating => "metadata:write:age_rating", PluginPermission::MetadataWriteLanguage => "metadata:write:language", PluginPermission::MetadataWriteReadingDirection => "metadata:write:reading_direction", - PluginPermission::MetadataWriteTotalBookCount => "metadata:write:total_book_count", PluginPermission::MetadataWriteTotalVolumeCount => "metadata:write:total_volume_count", PluginPermission::MetadataWriteTotalChapterCount => { "metadata:write:total_chapter_count" @@ -512,7 +504,6 @@ impl PluginPermission { PluginPermission::MetadataWriteAgeRating, PluginPermission::MetadataWriteLanguage, PluginPermission::MetadataWriteReadingDirection, - PluginPermission::MetadataWriteTotalBookCount, PluginPermission::MetadataWriteTotalVolumeCount, PluginPermission::MetadataWriteTotalChapterCount, // Book-specific write permissions @@ -548,7 +539,6 @@ impl PluginPermission { PluginPermission::MetadataWriteAgeRating, PluginPermission::MetadataWriteLanguage, PluginPermission::MetadataWriteReadingDirection, - PluginPermission::MetadataWriteTotalBookCount, PluginPermission::MetadataWriteTotalVolumeCount, PluginPermission::MetadataWriteTotalChapterCount, ] @@ -597,7 +587,6 @@ impl FromStr for PluginPermission { "metadata:write:reading_direction" => { Ok(PluginPermission::MetadataWriteReadingDirection) } - "metadata:write:total_book_count" => Ok(PluginPermission::MetadataWriteTotalBookCount), "metadata:write:total_volume_count" => { Ok(PluginPermission::MetadataWriteTotalVolumeCount) } @@ -716,7 +705,6 @@ impl Model { | PluginPermission::MetadataWriteAgeRating | PluginPermission::MetadataWriteLanguage | PluginPermission::MetadataWriteReadingDirection - | PluginPermission::MetadataWriteTotalBookCount | PluginPermission::MetadataWriteTotalVolumeCount | PluginPermission::MetadataWriteTotalChapterCount // Book-specific write permissions @@ -1022,8 +1010,6 @@ mod tests { assert!(model.has_permission(&PluginPermission::MetadataWriteTotalVolumeCount)); assert!(model.has_permission(&PluginPermission::MetadataWriteTotalChapterCount)); - // Sanity: still grants the legacy permission (kept until Phase 9). - assert!(model.has_permission(&PluginPermission::MetadataWriteTotalBookCount)); } #[test] @@ -1043,8 +1029,8 @@ mod tests { assert!(perms.contains(&PluginPermission::MetadataWriteExternalIds)); assert!(perms.contains(&PluginPermission::MetadataWriteTotalVolumeCount)); assert!(perms.contains(&PluginPermission::MetadataWriteTotalChapterCount)); - // Should have 29 write permissions (17 common + 12 book-specific) - assert_eq!(perms.len(), 29); + // Should have 28 write permissions (16 common + 12 book-specific) + assert_eq!(perms.len(), 28); } #[test] @@ -1052,15 +1038,14 @@ mod tests { let perms = PluginPermission::common_write_permissions(); assert!(perms.contains(&PluginPermission::MetadataWriteTitle)); assert!(perms.contains(&PluginPermission::MetadataWriteSummary)); - assert!(perms.contains(&PluginPermission::MetadataWriteTotalBookCount)); assert!(perms.contains(&PluginPermission::MetadataWriteTotalVolumeCount)); assert!(perms.contains(&PluginPermission::MetadataWriteTotalChapterCount)); // Book-specific should NOT be in common assert!(!perms.contains(&PluginPermission::MetadataWriteBookType)); assert!(!perms.contains(&PluginPermission::MetadataWriteIsbn)); assert!(perms.contains(&PluginPermission::MetadataWriteExternalIds)); - // Should have 17 common permissions - assert_eq!(perms.len(), 17); + // Should have 16 common permissions + assert_eq!(perms.len(), 16); } #[test] diff --git a/src/db/entities/series_metadata.rs b/src/db/entities/series_metadata.rs index 2da33fa3..b88f1293 100644 --- a/src/db/entities/series_metadata.rs +++ b/src/db/entities/series_metadata.rs @@ -96,7 +96,6 @@ pub struct Model { pub language: Option, // BCP47: "en", "ja", "ko" pub reading_direction: Option, // ltr, rtl, ttb pub year: Option, - pub total_book_count: Option, // Expected total (for ongoing series). DEPRECATED: removed in Phase 9 of metadata-count-split; new code reads/writes total_volume_count and total_chapter_count instead. /// Total volumes the series will/did have, when known. Use for volume-organized libraries. pub total_volume_count: Option, /// Total chapters the series will/did have, when known. May be fractional (e.g. 47.5). @@ -106,7 +105,6 @@ pub struct Model { /// Format: [{"name": "...", "role": "author|co-author|editor|...", "sort_name": "..."}] pub authors_json: Option, // Lock fields - pub total_book_count_lock: bool, // DEPRECATED: removed in Phase 9; replaced by total_volume_count_lock + total_chapter_count_lock. pub total_volume_count_lock: bool, pub total_chapter_count_lock: bool, pub title_lock: bool, diff --git a/src/db/repositories/series.rs b/src/db/repositories/series.rs index 0ac71b86..995d0c8f 100644 --- a/src/db/repositories/series.rs +++ b/src/db/repositories/series.rs @@ -753,13 +753,11 @@ impl SeriesRepository { language: Set(None), reading_direction: Set(None), year: Set(None), - total_book_count: Set(None), total_volume_count: Set(None), total_chapter_count: Set(None), custom_metadata: Set(None), authors_json: Set(None), // Lock fields default to false - total_book_count_lock: Set(false), total_volume_count_lock: Set(false), total_chapter_count_lock: Set(false), title_lock: Set(false), diff --git a/src/db/repositories/series_metadata.rs b/src/db/repositories/series_metadata.rs index 6b97ab8a..9fc27222 100644 --- a/src/db/repositories/series_metadata.rs +++ b/src/db/repositories/series_metadata.rs @@ -68,12 +68,10 @@ impl SeriesMetadataRepository { language: Set(None), reading_direction: Set(None), year: Set(None), - total_book_count: Set(None), total_volume_count: Set(None), total_chapter_count: Set(None), custom_metadata: Set(None), authors_json: Set(None), - total_book_count_lock: Set(false), total_volume_count_lock: Set(false), total_chapter_count_lock: Set(false), title_lock: Set(false), @@ -304,30 +302,6 @@ impl SeriesMetadataRepository { Ok(model) } - /// Update total book count (expected number of books in the series) - /// - /// DEPRECATED: kept through Phase 4 of metadata-count-split for the legacy column; - /// new callers should use [`update_total_volume_count`] and/or - /// [`update_total_chapter_count`]. Removed entirely in Phase 9. - pub async fn update_total_book_count( - db: &DatabaseConnection, - series_id: Uuid, - total_book_count: Option, - ) -> Result { - let existing = Self::get_by_series_id(db, series_id) - .await? - .ok_or_else(|| { - anyhow::anyhow!("Series metadata not found for series: {}", series_id) - })?; - - let mut active_model: series_metadata::ActiveModel = existing.into(); - active_model.total_book_count = Set(total_book_count); - active_model.updated_at = Set(Utc::now()); - - let model = active_model.update(db).await?; - Ok(model) - } - /// Update the expected total volume count for a series. /// /// Pass `None` to clear the value. The lock state is independent and unchanged. @@ -420,7 +394,6 @@ impl SeriesMetadataRepository { "language" => active_model.language_lock = Set(locked), "reading_direction" => active_model.reading_direction_lock = Set(locked), "year" => active_model.year_lock = Set(locked), - "total_book_count" => active_model.total_book_count_lock = Set(locked), "total_volume_count" => active_model.total_volume_count_lock = Set(locked), "total_chapter_count" => active_model.total_chapter_count_lock = Set(locked), "genres" => active_model.genres_lock = Set(locked), @@ -448,7 +421,6 @@ impl SeriesMetadataRepository { "language" => metadata.language_lock, "reading_direction" => metadata.reading_direction_lock, "year" => metadata.year_lock, - "total_book_count" => metadata.total_book_count_lock, "total_volume_count" => metadata.total_volume_count_lock, "total_chapter_count" => metadata.total_chapter_count_lock, "genres" => metadata.genres_lock, diff --git a/src/services/metadata/apply.rs b/src/services/metadata/apply.rs index d4617fea..b9b9be7a 100644 --- a/src/services/metadata/apply.rs +++ b/src/services/metadata/apply.rs @@ -8,7 +8,7 @@ use sea_orm::DatabaseConnection; use sea_orm::prelude::Decimal; use serde::{Deserialize, Serialize}; use std::collections::{HashMap, HashSet}; -use std::sync::{Arc, Mutex, OnceLock}; +use std::sync::Arc; use tracing::warn; use uuid::Uuid; @@ -337,25 +337,8 @@ impl MetadataApplier { } // Total Volume Count - // - // Backward-compat fallback (Phase 4 of metadata-count-split): if the - // plugin only sent the legacy `total_book_count` field, route its value - // into `total_volume_count` and emit a one-time deprecation warning per - // plugin. The fallback is removed in Phase 9 along with the legacy - // field on the protocol. - let effective_total_volume_count = match metadata.total_volume_count { - Some(v) => Some(v), - None => { - if let Some(legacy) = metadata.total_book_count { - warn_legacy_total_book_count(&plugin.name); - Some(legacy) - } else { - None - } - } - }; if should_apply_field("totalVolumeCount") - && let Some(total_volume_count) = effective_total_volume_count + && let Some(total_volume_count) = metadata.total_volume_count { let is_locked = current_metadata .map(|m| m.total_volume_count_lock) @@ -612,22 +595,3 @@ impl MetadataApplier { }) } } - -/// Emit a one-time-per-plugin deprecation warning when a plugin sends the -/// legacy `total_book_count` field instead of `total_volume_count`. Removed -/// in Phase 9 of the metadata-count-split plan. -fn warn_legacy_total_book_count(plugin_name: &str) { - static WARNED: OnceLock>> = OnceLock::new(); - let warned = WARNED.get_or_init(|| Mutex::new(HashSet::new())); - let mut guard = match warned.lock() { - Ok(g) => g, - Err(poisoned) => poisoned.into_inner(), - }; - if guard.insert(plugin_name.to_string()) { - warn!( - plugin = plugin_name, - "Plugin sent deprecated `total_book_count` field; routing to `total_volume_count`. \ - Update the plugin to emit `total_volume_count` and/or `total_chapter_count` directly." - ); - } -} diff --git a/src/services/metadata/preprocessing/context.rs b/src/services/metadata/preprocessing/context.rs index 8f8d9d0a..ab3e8175 100644 --- a/src/services/metadata/preprocessing/context.rs +++ b/src/services/metadata/preprocessing/context.rs @@ -107,11 +107,6 @@ pub struct MetadataContext { pub language: Option, pub reading_direction: Option, pub year: Option, - /// DEPRECATED: kept for one phase of backward-compat. Mirrors - /// `total_volume_count`. Removed in Phase 9 of the metadata-count-split - /// plan; preprocessing rules should reference `totalVolumeCount` or - /// `totalChapterCount` going forward. - pub total_book_count: Option, pub total_volume_count: Option, pub total_chapter_count: Option, @@ -150,9 +145,6 @@ pub struct MetadataContext { pub language_lock: bool, pub reading_direction_lock: bool, pub year_lock: bool, - /// DEPRECATED: kept for one phase of backward-compat alongside - /// `total_book_count`. Removed in Phase 9. - pub total_book_count_lock: bool, pub total_volume_count_lock: bool, pub total_chapter_count_lock: bool, pub genres_lock: bool, @@ -358,10 +350,6 @@ impl SeriesContext { .clone() .map(FieldValue::String), "year" => self.metadata.year.map(|v| FieldValue::Number(v as f64)), - "totalBookCount" | "total_book_count" => self - .metadata - .total_book_count - .map(|v| FieldValue::Number(v as f64)), "totalVolumeCount" | "total_volume_count" => self .metadata .total_volume_count @@ -444,9 +432,6 @@ impl SeriesContext { Some(FieldValue::Bool(self.metadata.reading_direction_lock)) } "yearLock" | "year_lock" => Some(FieldValue::Bool(self.metadata.year_lock)), - "totalBookCountLock" | "total_book_count_lock" => { - Some(FieldValue::Bool(self.metadata.total_book_count_lock)) - } "totalVolumeCountLock" | "total_volume_count_lock" => { Some(FieldValue::Bool(self.metadata.total_volume_count_lock)) } @@ -627,7 +612,6 @@ impl SeriesContextBuilder { language: m.language.clone(), reading_direction: m.reading_direction.clone(), year: m.year, - total_book_count: m.total_volume_count, total_volume_count: m.total_volume_count, total_chapter_count: m.total_chapter_count, genres: genres.iter().map(|g| g.name.clone()).collect(), @@ -646,7 +630,6 @@ impl SeriesContextBuilder { language_lock: m.language_lock, reading_direction_lock: m.reading_direction_lock, year_lock: m.year_lock, - total_book_count_lock: m.total_volume_count_lock, total_volume_count_lock: m.total_volume_count_lock, total_chapter_count_lock: m.total_chapter_count_lock, genres_lock: m.genres_lock, @@ -1547,7 +1530,7 @@ mod tests { title_sort: Some("One Piece".to_string()), age_rating: Some(13), reading_direction: Some("rtl".to_string()), - total_book_count: Some(100), + total_volume_count: Some(100), genres: vec!["Action".to_string(), "Adventure".to_string()], tags: vec!["pirates".to_string(), "treasure".to_string()], title_lock: true, @@ -1566,8 +1549,8 @@ mod tests { "readingDirection should exist" ); assert!( - json.get("totalBookCount").is_some(), - "totalBookCount should exist" + json.get("totalVolumeCount").is_some(), + "totalVolumeCount should exist" ); assert!(json.get("titleLock").is_some(), "titleLock should exist"); assert!(json.get("genresLock").is_some(), "genresLock should exist"); @@ -1590,8 +1573,8 @@ mod tests { "reading_direction should not exist" ); assert!( - json.get("total_book_count").is_none(), - "total_book_count should not exist" + json.get("total_volume_count").is_none(), + "total_volume_count should not exist" ); assert!( json.get("title_lock").is_none(), @@ -1603,7 +1586,7 @@ mod tests { assert_eq!(json["titleSort"], "One Piece"); assert_eq!(json["ageRating"], 13); assert_eq!(json["readingDirection"], "rtl"); - assert_eq!(json["totalBookCount"], 100); + assert_eq!(json["totalVolumeCount"], 100); assert_eq!(json["titleLock"], true); assert_eq!(json["genres"], serde_json::json!(["Action", "Adventure"])); assert_eq!(json["tags"], serde_json::json!(["pirates", "treasure"])); @@ -1669,7 +1652,7 @@ mod tests { title_sort: Some("One Piece".to_string()), age_rating: Some(13), reading_direction: Some("rtl".to_string()), - total_book_count: Some(100), + total_volume_count: Some(100), title_sort_lock: true, ..Default::default() }; @@ -1693,7 +1676,7 @@ mod tests { Some(FieldValue::String("rtl".to_string())) ); assert_eq!( - context.get_field("metadata.totalBookCount"), + context.get_field("metadata.totalVolumeCount"), Some(FieldValue::Number(100.0)) ); assert_eq!( diff --git a/src/services/plugin/library.rs b/src/services/plugin/library.rs index af32d807..5d8056e2 100644 --- a/src/services/plugin/library.rs +++ b/src/services/plugin/library.rs @@ -169,10 +169,6 @@ pub async fn build_user_library( }), genres, tags, - // Legacy `total_book_count` mirrors `total_volume_count` for one phase - // to keep older plugins compatible. Removed in Phase 9 of the - // metadata-count-split plan. - total_book_count: meta.and_then(|m| m.total_volume_count), total_volume_count: meta.and_then(|m| m.total_volume_count), total_chapter_count: meta.and_then(|m| m.total_chapter_count), external_ids, diff --git a/src/services/plugin/protocol.rs b/src/services/plugin/protocol.rs index 0355301b..fcaa6715 100644 --- a/src/services/plugin/protocol.rs +++ b/src/services/plugin/protocol.rs @@ -15,13 +15,14 @@ pub const JSONRPC_VERSION: &str = "2.0"; /// Plugin protocol version /// -/// Bumped to 1.1 (additive minor) when `total_volume_count` + `total_chapter_count` -/// were added to `PluginSeriesMetadata` / `UserLibraryEntry` and -/// `MetadataWriteTotalVolumeCount` / `MetadataWriteTotalChapterCount` permissions were -/// introduced. Plugins built against 1.0 still deserialize cleanly (legacy -/// `total_book_count` remains in the schema). +/// - 1.1 (additive minor): added `total_volume_count` + `total_chapter_count` and the +/// matching `MetadataWriteTotalVolumeCount` / `MetadataWriteTotalChapterCount` +/// permissions; legacy `total_book_count` still accepted on the wire. +/// - 1.2 (breaking minor): legacy `total_book_count` field and +/// `MetadataWriteTotalBookCount` permission removed; plugins must populate the split +/// counts directly. #[allow(dead_code)] // Protocol contract: sent to plugins during initialize -pub const PROTOCOL_VERSION: &str = "1.1"; +pub const PROTOCOL_VERSION: &str = "1.2"; // ============================================================================= // JSON-RPC Base Types @@ -781,13 +782,6 @@ pub struct PluginSeriesMetadata { pub year: Option, // Extended metadata - /// Expected total number of books in the series. - /// - /// DEPRECATED: kept for one phase of backward-compat with older plugins. Plugins - /// should populate `total_volume_count` and/or `total_chapter_count` instead. - /// Removed in Phase 9 of the metadata-count-split plan. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub total_book_count: Option, /// Expected total number of volumes in the series, when known. /// Use this for volume-organized libraries. #[serde(default, skip_serializing_if = "Option::is_none")] @@ -1215,13 +1209,6 @@ pub struct UserLibraryEntry { /// Tags #[serde(default, skip_serializing_if = "Vec::is_empty")] pub tags: Vec, - /// Total book count in the series. - /// - /// DEPRECATED: kept for one phase of backward-compat with older plugins. Plugins - /// should consume `total_volume_count` and/or `total_chapter_count` instead. - /// Removed in Phase 9 of the metadata-count-split plan. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub total_book_count: Option, /// Expected total number of volumes in the series, when known. #[serde(default, skip_serializing_if = "Option::is_none")] pub total_volume_count: Option, @@ -1549,7 +1536,6 @@ mod tests { summary: Some("A pirate adventure".to_string()), status: Some(SeriesStatus::Ongoing), year: Some(1997), - total_book_count: Some(100), total_volume_count: Some(100), total_chapter_count: Some(1086.0), language: Some("ja".to_string()), @@ -1628,7 +1614,6 @@ mod tests { summary: None, status: None, year: None, - total_book_count: None, total_volume_count: None, total_chapter_count: None, language: None, @@ -1664,8 +1649,6 @@ mod tests { let parsed: PluginSeriesMetadata = serde_json::from_value(json).unwrap(); assert_eq!(parsed.total_volume_count, Some(14)); assert_eq!(parsed.total_chapter_count, Some(109.5)); - // Legacy field is None when only the new fields are sent. - assert_eq!(parsed.total_book_count, None); // Round-trip back to JSON preserves both fields. let serialized = serde_json::to_value(&parsed).unwrap(); @@ -1674,17 +1657,16 @@ mod tests { } #[test] - fn test_plugin_series_metadata_legacy_total_book_count_still_deserializes() { - // An old plugin that only emits the legacy field must still parse cleanly, - // with the new fields absent (None). The Phase 4 fallback in MetadataApplier - // is what routes this to total_volume_count at write time. + fn test_plugin_series_metadata_legacy_total_book_count_is_ignored() { + // Protocol 1.2 dropped the legacy field. Older plugins that still emit it + // must still parse (serde ignores unknown fields by default), but the + // value is silently discarded - there is no longer a routing path for it. let json = json!({ "externalId": "old-1", "externalUrl": "https://example.com/old-1", "totalBookCount": 14, }); let parsed: PluginSeriesMetadata = serde_json::from_value(json).unwrap(); - assert_eq!(parsed.total_book_count, Some(14)); assert_eq!(parsed.total_volume_count, None); assert_eq!(parsed.total_chapter_count, None); } @@ -1700,7 +1682,6 @@ mod tests { summary: None, status: None, year: None, - total_book_count: None, total_volume_count: None, total_chapter_count: None, language: None, @@ -1738,7 +1719,6 @@ mod tests { let parsed: UserLibraryEntry = serde_json::from_value(json).unwrap(); assert_eq!(parsed.total_volume_count, Some(107)); assert_eq!(parsed.total_chapter_count, Some(1086.5)); - assert_eq!(parsed.total_book_count, None); let serialized = serde_json::to_value(&parsed).unwrap(); assert_eq!(serialized["totalVolumeCount"], 107); @@ -1747,10 +1727,11 @@ mod tests { #[test] fn test_protocol_version_is_minor_bumped() { - // Phase 2 of metadata-count-split bumps the protocol from 1.0 to 1.1 - // (additive minor). Older 1.0 plugins continue to deserialize because - // total_book_count is still on the wire. - assert_eq!(PROTOCOL_VERSION, "1.1"); + // Phase 9 of metadata-count-split bumps the protocol from 1.1 to 1.2: + // legacy `totalBookCount` field and `metadata:write:total_book_count` + // permission are removed. Plugins that still emit the legacy field + // round-trip through serde silently (the field is dropped on decode). + assert_eq!(PROTOCOL_VERSION, "1.2"); } #[test] @@ -2284,7 +2265,6 @@ mod tests { status: Some(SeriesStatus::Ongoing), genres: vec!["Action".to_string(), "Adventure".to_string()], tags: vec!["pirates".to_string()], - total_book_count: Some(107), total_volume_count: Some(107), total_chapter_count: Some(1086.5), external_ids: vec![UserLibraryExternalId { @@ -2308,7 +2288,8 @@ mod tests { assert_eq!(json["year"], 1997); assert_eq!(json["status"], "ongoing"); assert_eq!(json["genres"].as_array().unwrap().len(), 2); - assert_eq!(json["totalBookCount"], 107); + assert_eq!(json["totalVolumeCount"], 107); + assert_eq!(json["totalChapterCount"], 1086.5); assert_eq!(json["externalIds"][0]["source"], "anilist"); assert_eq!(json["externalIds"][0]["externalId"], "21"); assert_eq!(json["readingStatus"], "reading"); @@ -2329,7 +2310,6 @@ mod tests { status: None, genres: vec![], tags: vec![], - total_book_count: None, total_volume_count: None, total_chapter_count: None, external_ids: vec![], @@ -2435,7 +2415,6 @@ mod tests { status: None, genres: vec![], tags: vec![], - total_book_count: None, total_volume_count: None, total_chapter_count: None, external_ids: vec![ diff --git a/src/services/plugin/recommendations.rs b/src/services/plugin/recommendations.rs index a46e6458..07552657 100644 --- a/src/services/plugin/recommendations.rs +++ b/src/services/plugin/recommendations.rs @@ -126,14 +126,6 @@ pub struct Recommendation { /// Year the series started #[serde(default, skip_serializing_if = "Option::is_none")] pub start_year: Option, - /// Total expected number of books/volumes in the series. - /// - /// DEPRECATED: kept for one phase of backward-compat with older - /// recommendation plugins. Plugins should populate `total_volume_count` - /// and/or `total_chapter_count` instead. Removed in Phase 9 of the - /// metadata-count-split plan. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub total_book_count: Option, /// Total expected number of volumes in the series, when known. #[serde(default, skip_serializing_if = "Option::is_none")] pub total_volume_count: Option, @@ -268,7 +260,6 @@ mod tests { status: None, genres: vec!["Action".to_string(), "Dark Fantasy".to_string()], tags: vec![], - total_book_count: Some(41), total_volume_count: Some(41), total_chapter_count: None, external_ids: vec![], @@ -338,7 +329,6 @@ mod tests { format: None, country_of_origin: None, start_year: None, - total_book_count: Some(27), total_volume_count: Some(27), total_chapter_count: None, rating: Some(85), @@ -352,7 +342,7 @@ mod tests { assert_eq!(json["recommendations"][0]["title"], "Vinland Saga"); assert_eq!(json["recommendations"][0]["score"], 0.95); assert_eq!(json["recommendations"][0]["status"], "ongoing"); - assert_eq!(json["recommendations"][0]["totalBookCount"], 27); + assert_eq!(json["recommendations"][0]["totalVolumeCount"], 27); assert_eq!(json["recommendations"][0]["rating"], 85); assert_eq!(json["recommendations"][0]["popularity"], 120000); assert_eq!(json["generatedAt"], "2026-02-06T12:00:00Z"); @@ -409,7 +399,6 @@ mod tests { format: Some("MANGA".to_string()), country_of_origin: Some("JP".to_string()), start_year: Some(1994), - total_book_count: Some(18), total_volume_count: Some(18), total_chapter_count: Some(162.0), rating: Some(92), @@ -431,7 +420,6 @@ mod tests { assert_eq!(json["codexSeriesId"], "codex-uuid-123"); assert!(json["inLibrary"].as_bool().unwrap()); assert_eq!(json["status"], "ended"); - assert_eq!(json["totalBookCount"], 18); assert_eq!(json["totalVolumeCount"], 18); assert_eq!(json["totalChapterCount"], 162.0); assert_eq!(json["rating"], 92); @@ -463,7 +451,6 @@ mod tests { assert!(rec.format.is_none()); assert!(rec.country_of_origin.is_none()); assert!(rec.start_year.is_none()); - assert!(rec.total_book_count.is_none()); assert!(rec.total_volume_count.is_none()); assert!(rec.total_chapter_count.is_none()); assert!(rec.rating.is_none()); @@ -489,7 +476,6 @@ mod tests { format: None, country_of_origin: None, start_year: None, - total_book_count: None, total_volume_count: None, total_chapter_count: None, rating: None, @@ -530,7 +516,6 @@ mod tests { status: None, genres: vec!["Adventure".to_string()], tags: vec![], - total_book_count: None, total_volume_count: None, total_chapter_count: None, external_ids: vec![], diff --git a/tests/api/plugins.rs b/tests/api/plugins.rs index 78bdccf1..1fa4a0f7 100644 --- a/tests/api/plugins.rs +++ b/tests/api/plugins.rs @@ -1630,7 +1630,7 @@ async fn test_series_context_field_access_dual_path_support() { title_sort: Some("Test Series".to_string()), age_rating: Some(13), reading_direction: Some("rtl".to_string()), - total_book_count: Some(50), + total_volume_count: Some(50), genres: vec!["Action".to_string(), "Drama".to_string()], tags: vec!["fantasy".to_string()], title_lock: true, @@ -1660,7 +1660,7 @@ async fn test_series_context_field_access_dual_path_support() { Some(FieldValue::String("rtl".to_string())) ); assert_eq!( - context.get_field("metadata.totalBookCount"), + context.get_field("metadata.totalVolumeCount"), Some(FieldValue::Number(50.0)) ); assert_eq!( diff --git a/tests/db/migrations.rs b/tests/db/migrations.rs index 8259f4eb..0ccc8ec8 100644 --- a/tests/db/migrations.rs +++ b/tests/db/migrations.rs @@ -390,8 +390,9 @@ async fn setup_db_before_migration_067() -> (Database, TempDir) { let db = Database::new(&config).await.unwrap(); let conn = db.sea_orm_connection(); - // Run all migrations except the last one (067 = split_book_count). - // Total migrations after adding 067 is 64; running 63 leaves 067 pending. + // Run all migrations except 067 + 068 (the count-split + drop pair). + // Total migrations after adding 068 is 65; running 63 leaves 067 and 068 + // both pending so each test below can apply them step-by-step via Some(1). Migrator::up(conn, Some(63)).await.unwrap(); (db, temp_dir) @@ -450,15 +451,17 @@ async fn test_migration_067_backfill_sqlite() { VALUES ({s_lock_only}, 'Empty Locked', NULL, 1, '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z')" )).await.unwrap(); - // Run migration 067. - Migrator::up(conn, None).await.unwrap(); + // Run migration 067 only (one step), so the legacy column is still present + // and we can verify backfill semantics in isolation. Migration 068 (drop) + // is exercised by `test_migration_068_drop_legacy_sqlite` below. + Migrator::up(conn, Some(1)).await.unwrap(); // Post-conditions: new columns present. assert!(sqlite_has_column(conn, "series_metadata", "total_volume_count").await); assert!(sqlite_has_column(conn, "series_metadata", "total_volume_count_lock").await); assert!(sqlite_has_column(conn, "series_metadata", "total_chapter_count").await); assert!(sqlite_has_column(conn, "series_metadata", "total_chapter_count_lock").await); - // Legacy columns still present (kept until Phase 9). + // Legacy columns still present after 067 (dropped by 068). assert!(sqlite_has_column(conn, "series_metadata", "total_book_count").await); assert!(sqlite_has_column(conn, "series_metadata", "total_book_count_lock").await); @@ -505,3 +508,33 @@ async fn test_migration_067_backfill_sqlite() { db.close().await; } + +// -- Migration 068 (drop_book_count) tests -- +// Verifies the Phase 9 hard-removal migration drops the legacy total_book_count +// + total_book_count_lock columns while leaving the split-count columns intact. + +#[tokio::test] +async fn test_migration_068_drop_legacy_sqlite() { + let (db, _temp_dir) = setup_db_before_migration_067().await; + let conn = db.sea_orm_connection(); + + // Apply 067 first so the new columns exist alongside the legacy pair. + Migrator::up(conn, Some(1)).await.unwrap(); + assert!(sqlite_has_column(conn, "series_metadata", "total_book_count").await); + assert!(sqlite_has_column(conn, "series_metadata", "total_book_count_lock").await); + assert!(sqlite_has_column(conn, "series_metadata", "total_volume_count").await); + assert!(sqlite_has_column(conn, "series_metadata", "total_chapter_count").await); + + // Apply 068 (drop the legacy columns). + Migrator::up(conn, None).await.unwrap(); + + // Legacy columns are gone; split-count columns survive. + assert!(!sqlite_has_column(conn, "series_metadata", "total_book_count").await); + assert!(!sqlite_has_column(conn, "series_metadata", "total_book_count_lock").await); + assert!(sqlite_has_column(conn, "series_metadata", "total_volume_count").await); + assert!(sqlite_has_column(conn, "series_metadata", "total_volume_count_lock").await); + assert!(sqlite_has_column(conn, "series_metadata", "total_chapter_count").await); + assert!(sqlite_has_column(conn, "series_metadata", "total_chapter_count_lock").await); + + db.close().await; +} diff --git a/tests/services/metadata_apply.rs b/tests/services/metadata_apply.rs index adb43dc8..61eca513 100644 --- a/tests/services/metadata_apply.rs +++ b/tests/services/metadata_apply.rs @@ -68,7 +68,6 @@ fn create_test_metadata(title: &str) -> PluginSeriesMetadata { summary: None, status: None, year: None, - total_book_count: None, total_volume_count: None, total_chapter_count: None, language: None, @@ -345,7 +344,6 @@ fn metadata_with_counts( summary: None, status: None, year: None, - total_book_count: None, total_volume_count, total_chapter_count, language: None, @@ -747,174 +745,6 @@ async fn test_apply_count_fields_filtered_out_by_allowlist() { assert!(after.total_chapter_count.is_none()); } -// ============================================================================= -// Legacy `total_book_count` Backward-Compat Fallback Tests (Phase 4) -// ============================================================================= - -fn metadata_with_legacy_book_count(total_book_count: Option) -> PluginSeriesMetadata { - PluginSeriesMetadata { - external_id: "legacy-1".to_string(), - external_url: "https://example.com/legacy-1".to_string(), - title: None, - alternate_titles: vec![], - summary: None, - status: None, - year: None, - total_book_count, - total_volume_count: None, - total_chapter_count: None, - language: None, - age_rating: None, - reading_direction: None, - genres: vec![], - tags: vec![], - authors: vec![], - artists: vec![], - publisher: None, - cover_url: None, - banner_url: None, - rating: None, - external_ratings: vec![], - external_links: vec![], - external_ids: vec![], - } -} - -#[tokio::test] -async fn test_apply_legacy_total_book_count_routes_to_total_volume_count() { - let (db, _temp_dir) = setup_test_db().await; - let library = - LibraryRepository::create(&db, "Test Library", "/test/path", ScanningStrategy::Default) - .await - .unwrap(); - let series = SeriesRepository::create(&db, library.id, "Series", None) - .await - .unwrap(); - let current = SeriesMetadataRepository::get_by_series_id(&db, series.id) - .await - .unwrap() - .unwrap(); - - // Plugin only knows the `total_volume_count` permission (the new field) — - // the fallback should still apply because we re-route the legacy value. - let plugin = create_plugin_with_permissions(&["metadata:write:total_volume_count"]); - let result = MetadataApplier::apply( - &db, - series.id, - library.id, - &plugin, - &metadata_with_legacy_book_count(Some(14)), - Some(¤t), - &ApplyOptions::default(), - ) - .await - .unwrap(); - - assert!( - result - .applied_fields - .contains(&"totalVolumeCount".to_string()), - "totalVolumeCount should be applied via the legacy fallback" - ); - let updated = SeriesMetadataRepository::get_by_series_id(&db, series.id) - .await - .unwrap() - .unwrap(); - assert_eq!(updated.total_volume_count, Some(14)); - // Legacy column is no longer written by apply() — Phase 4 freezes it. - assert_eq!( - updated.total_book_count, None, - "legacy total_book_count column should not be written" - ); -} - -#[tokio::test] -async fn test_apply_new_total_volume_count_takes_precedence_over_legacy() { - let (db, _temp_dir) = setup_test_db().await; - let library = - LibraryRepository::create(&db, "Test Library", "/test/path", ScanningStrategy::Default) - .await - .unwrap(); - let series = SeriesRepository::create(&db, library.id, "Series", None) - .await - .unwrap(); - let current = SeriesMetadataRepository::get_by_series_id(&db, series.id) - .await - .unwrap() - .unwrap(); - - // Plugin sends both old and new shape; the new field must win. - let mut metadata = metadata_with_legacy_book_count(Some(99)); - metadata.total_volume_count = Some(14); - - let plugin = create_plugin_with_permissions(&["metadata:write:total_volume_count"]); - MetadataApplier::apply( - &db, - series.id, - library.id, - &plugin, - &metadata, - Some(¤t), - &ApplyOptions::default(), - ) - .await - .unwrap(); - - let updated = SeriesMetadataRepository::get_by_series_id(&db, series.id) - .await - .unwrap() - .unwrap(); - assert_eq!( - updated.total_volume_count, - Some(14), - "new field must take precedence when both are sent" - ); -} - -#[tokio::test] -async fn test_apply_does_not_write_legacy_total_book_count_column() { - let (db, _temp_dir) = setup_test_db().await; - let library = - LibraryRepository::create(&db, "Test Library", "/test/path", ScanningStrategy::Default) - .await - .unwrap(); - let series = SeriesRepository::create(&db, library.id, "Series", None) - .await - .unwrap(); - let current = SeriesMetadataRepository::get_by_series_id(&db, series.id) - .await - .unwrap() - .unwrap(); - - // Even with the deprecated permission granted, apply() must not write the - // legacy `total_book_count` column. Phase 4 stops the write entirely. - let plugin = create_plugin_with_permissions(&[ - "metadata:write:total_book_count", - "metadata:write:total_volume_count", - ]); - MetadataApplier::apply( - &db, - series.id, - library.id, - &plugin, - &metadata_with_legacy_book_count(Some(14)), - Some(¤t), - &ApplyOptions::default(), - ) - .await - .unwrap(); - - let updated = SeriesMetadataRepository::get_by_series_id(&db, series.id) - .await - .unwrap() - .unwrap(); - assert_eq!( - updated.total_book_count, None, - "legacy total_book_count column must remain frozen after Phase 4" - ); - assert_eq!(updated.total_volume_count, Some(14)); -} - #[tokio::test] async fn test_apply_count_fields_skip_when_metadata_value_absent() { let (db, _temp_dir) = setup_test_db().await; diff --git a/web/src/api/plugins.ts b/web/src/api/plugins.ts index 0b6eccca..b896876e 100644 --- a/web/src/api/plugins.ts +++ b/web/src/api/plugins.ts @@ -103,7 +103,8 @@ export type PluginPermission = | "metadata:write:age_rating" | "metadata:write:language" | "metadata:write:reading_direction" - | "metadata:write:total_book_count" + | "metadata:write:total_volume_count" + | "metadata:write:total_chapter_count" | "metadata:write:*" | "library:read"; @@ -140,8 +141,12 @@ export const AVAILABLE_PERMISSIONS: { label: "Write Reading Direction", }, { - value: "metadata:write:total_book_count", - label: "Write Total Book Count", + value: "metadata:write:total_volume_count", + label: "Write Total Volume Count", + }, + { + value: "metadata:write:total_chapter_count", + label: "Write Total Chapter Count", }, { value: "library:read", label: "Read Library" }, ]; diff --git a/web/src/components/metadata/MetadataPreview.tsx b/web/src/components/metadata/MetadataPreview.tsx index 90511e18..9b5102a7 100644 --- a/web/src/components/metadata/MetadataPreview.tsx +++ b/web/src/components/metadata/MetadataPreview.tsx @@ -93,7 +93,8 @@ const FIELD_LABELS: Record = { language: "Language", ageRating: "Age Rating", readingDirection: "Reading Direction", - totalBookCount: "Total Books", + totalVolumeCount: "Total Volumes", + totalChapterCount: "Total Chapters", externalLinks: "External Links", rating: "Rating", externalRatings: "External Ratings", diff --git a/web/src/components/metadata/MetadataSearchModal.tsx b/web/src/components/metadata/MetadataSearchModal.tsx index e589c5b4..3920fe60 100644 --- a/web/src/components/metadata/MetadataSearchModal.tsx +++ b/web/src/components/metadata/MetadataSearchModal.tsx @@ -152,7 +152,7 @@ export function MetadataSearchModal({ : [], bookCount: contentType === "series" - ? (metadata.totalBookCount as number | undefined) + ? (metadata.totalVolumeCount as number | undefined) : undefined, authors: Array.isArray(metadata.authors) ? (metadata.authors as unknown[]).map((a: unknown) => diff --git a/web/src/components/recommendations/RecommendationCard.test.tsx b/web/src/components/recommendations/RecommendationCard.test.tsx index 7d147511..712b1e7a 100644 --- a/web/src/components/recommendations/RecommendationCard.test.tsx +++ b/web/src/components/recommendations/RecommendationCard.test.tsx @@ -240,10 +240,10 @@ describe("RecommendationCard", () => { expect(screen.queryByText("Ended")).not.toBeInTheDocument(); }); - it("renders total book count when provided", () => { + it("renders total volume count when provided", () => { renderWithProviders( , ); diff --git a/web/src/components/recommendations/RecommendationCard.tsx b/web/src/components/recommendations/RecommendationCard.tsx index 821c6275..5aac9a92 100644 --- a/web/src/components/recommendations/RecommendationCard.tsx +++ b/web/src/components/recommendations/RecommendationCard.tsx @@ -80,7 +80,8 @@ export function RecommendationCard({ status, format, countryOfOrigin, - totalBookCount, + totalVolumeCount, + totalChapterCount, rating, popularity, } = recommendation; @@ -183,14 +184,24 @@ export function RecommendationCard({ : format} )} - {totalBookCount != null && ( + {totalVolumeCount != null && ( } > - {totalBookCount} vol + {totalVolumeCount} vol + + )} + {totalChapterCount != null && ( + } + > + {totalChapterCount} ch )} {rating != null && ( diff --git a/web/src/components/series/CustomMetadataDisplay.test.tsx b/web/src/components/series/CustomMetadataDisplay.test.tsx index e02d91cc..56b5eedb 100644 --- a/web/src/components/series/CustomMetadataDisplay.test.tsx +++ b/web/src/components/series/CustomMetadataDisplay.test.tsx @@ -24,7 +24,8 @@ function createMockContext( language: null, status: null, readingDirection: null, - totalBookCount: null, + totalVolumeCount: null, + totalChapterCount: null, genres: [], tags: [], alternateTitles: [], @@ -41,7 +42,8 @@ function createMockContext( languageLock: false, readingDirectionLock: false, yearLock: false, - totalBookCountLock: false, + totalVolumeCountLock: false, + totalChapterCountLock: false, genresLock: false, tagsLock: false, customMetadataLock: false, @@ -408,7 +410,8 @@ describe("CustomMetadataDisplay", () => { language: null, status: null, readingDirection: null, - totalBookCount: null, + totalVolumeCount: null, + totalChapterCount: null, genres: [], tags: [], titleLock: false, @@ -421,7 +424,8 @@ describe("CustomMetadataDisplay", () => { languageLock: false, readingDirectionLock: false, yearLock: false, - totalBookCountLock: false, + totalVolumeCountLock: false, + totalChapterCountLock: false, genresLock: false, tagsLock: false, customMetadataLock: false, @@ -451,7 +455,8 @@ describe("CustomMetadataDisplay", () => { language: null, status: null, readingDirection: null, - totalBookCount: null, + totalVolumeCount: null, + totalChapterCount: null, genres: ["Action", "Dark Fantasy", "Drama"], tags: [], titleLock: false, @@ -464,7 +469,8 @@ describe("CustomMetadataDisplay", () => { languageLock: false, readingDirectionLock: false, yearLock: false, - totalBookCountLock: false, + totalVolumeCountLock: false, + totalChapterCountLock: false, genresLock: false, tagsLock: false, customMetadataLock: false, @@ -494,7 +500,8 @@ describe("CustomMetadataDisplay", () => { language: null, status: null, readingDirection: null, - totalBookCount: null, + totalVolumeCount: null, + totalChapterCount: null, genres: [], tags: ["manga", "titans", "survival"], titleLock: false, @@ -507,7 +514,8 @@ describe("CustomMetadataDisplay", () => { languageLock: false, readingDirectionLock: false, yearLock: false, - totalBookCountLock: false, + totalVolumeCountLock: false, + totalChapterCountLock: false, genresLock: false, tagsLock: false, customMetadataLock: false, @@ -535,7 +543,8 @@ describe("CustomMetadataDisplay", () => { language: null, status: null, readingDirection: null, - totalBookCount: null, + totalVolumeCount: null, + totalChapterCount: null, genres: ["Action", "Drama"], tags: [], titleLock: false, @@ -548,7 +557,8 @@ describe("CustomMetadataDisplay", () => { languageLock: false, readingDirectionLock: false, yearLock: false, - totalBookCountLock: false, + totalVolumeCountLock: false, + totalChapterCountLock: false, genresLock: false, tagsLock: false, customMetadataLock: false, @@ -583,7 +593,8 @@ Status: {{customMetadata.status}}`} language: null, status: "ended", readingDirection: null, - totalBookCount: null, + totalVolumeCount: null, + totalChapterCount: null, genres: [], tags: [], titleLock: false, @@ -596,7 +607,8 @@ Status: {{customMetadata.status}}`} languageLock: false, readingDirectionLock: false, yearLock: false, - totalBookCountLock: false, + totalVolumeCountLock: false, + totalChapterCountLock: false, genresLock: false, tagsLock: false, customMetadataLock: false, @@ -633,7 +645,8 @@ Status: {{customMetadata.status}}`} language: null, status: null, readingDirection: null, - totalBookCount: null, + totalVolumeCount: null, + totalChapterCount: null, genres: [], tags: [], titleLock: false, @@ -646,7 +659,8 @@ Status: {{customMetadata.status}}`} languageLock: false, readingDirectionLock: false, yearLock: false, - totalBookCountLock: false, + totalVolumeCountLock: false, + totalChapterCountLock: false, genresLock: false, tagsLock: false, customMetadataLock: false, diff --git a/web/src/components/series/seriesCounts.ts b/web/src/components/series/seriesCounts.ts index e8b22cf2..9e09ffd4 100644 --- a/web/src/components/series/seriesCounts.ts +++ b/web/src/components/series/seriesCounts.ts @@ -3,9 +3,7 @@ * * Inputs come from `series.bookCount` (local count) and `series.metadata` * (`totalVolumeCount`, `totalChapterCount`). Either total may be null/undefined - * when the metadata provider didn't expose it. The legacy `totalBookCount` on - * the wire still mirrors `totalVolumeCount` until Phase 9; we read the new - * field exclusively here so call sites stop fanning out the fallback. + * when the metadata provider didn't expose it. */ export interface SeriesCountInputs { diff --git a/web/src/data/exampleTemplates.ts b/web/src/data/exampleTemplates.ts index dd775d56..837a720d 100644 --- a/web/src/data/exampleTemplates.ts +++ b/web/src/data/exampleTemplates.ts @@ -329,8 +329,12 @@ const seriesInfoTemplate: ExampleTemplate = { **Status:** {{capitalize metadata.status}} {{/if}} -{{#if metadata.totalBookCount}} -**Volumes:** {{metadata.totalBookCount}} +{{#if metadata.totalVolumeCount}} +**Volumes:** {{metadata.totalVolumeCount}} +{{/if}} + +{{#if metadata.totalChapterCount}} +**Chapters:** {{metadata.totalChapterCount}} {{/if}} {{#if metadata.ageRating}} @@ -406,7 +410,7 @@ const completeOverviewTemplate: ExampleTemplate = { {{/if}} {{#if custom_metadata.current_volume}} -**Currently on:** Volume {{custom_metadata.current_volume}}{{#if metadata.totalBookCount}} of {{metadata.totalBookCount}}{{/if}} +**Currently on:** Volume {{custom_metadata.current_volume}}{{#if metadata.totalVolumeCount}} of {{metadata.totalVolumeCount}}{{/if}} {{/if}} {{#if custom_metadata.notes}} @@ -487,7 +491,8 @@ const kitchenSinkTemplate: ExampleTemplate = { {{#if metadata.publisher}}| Publisher | {{metadata.publisher}}{{#if metadata.imprint}} ({{metadata.imprint}}){{/if}} |{{/if}} {{#if metadata.year}}| Year | {{metadata.year}} |{{/if}} {{#if metadata.status}}| Status | {{capitalize metadata.status}} |{{/if}} -{{#if metadata.totalBookCount}}| Volumes | {{metadata.totalBookCount}} |{{/if}} +{{#if metadata.totalVolumeCount}}| Volumes | {{metadata.totalVolumeCount}} |{{/if}} +{{#if metadata.totalChapterCount}}| Chapters | {{metadata.totalChapterCount}} |{{/if}} {{#if metadata.ageRating}}| Age Rating | {{metadata.ageRating}}+ |{{/if}} {{#if metadata.language}}| Language | {{metadata.language}} |{{/if}} diff --git a/web/src/mocks/handlers/recommendations.ts b/web/src/mocks/handlers/recommendations.ts index c917a35c..b4e548a2 100644 --- a/web/src/mocks/handlers/recommendations.ts +++ b/web/src/mocks/handlers/recommendations.ts @@ -38,7 +38,7 @@ let mockRecommendations: RecommendationDto[] = [ inLibrary: !!onePiece, inCodex: !!onePiece, status: "ongoing", - totalBookCount: 110, + totalVolumeCount: 110, rating: 88, popularity: 234000, }, @@ -57,7 +57,7 @@ let mockRecommendations: RecommendationDto[] = [ inLibrary: !!attackOnTitan, inCodex: !!attackOnTitan, status: "ended", - totalBookCount: 34, + totalVolumeCount: 34, rating: 84, popularity: 302000, }, @@ -75,7 +75,7 @@ let mockRecommendations: RecommendationDto[] = [ inLibrary: true, inCodex: false, status: "hiatus", - totalBookCount: 42, + totalVolumeCount: 42, rating: 93, popularity: 185000, }, @@ -93,7 +93,7 @@ let mockRecommendations: RecommendationDto[] = [ inLibrary: true, inCodex: false, status: "ended", - totalBookCount: 27, + totalVolumeCount: 27, rating: 89, popularity: 95000, }, @@ -111,7 +111,7 @@ let mockRecommendations: RecommendationDto[] = [ inLibrary: false, inCodex: false, status: "hiatus", - totalBookCount: 37, + totalVolumeCount: 37, rating: 90, popularity: 112000, }, @@ -130,7 +130,7 @@ let mockRecommendations: RecommendationDto[] = [ inLibrary: false, inCodex: true, status: "ended", - totalBookCount: 27, + totalVolumeCount: 27, rating: 88, popularity: 198000, }, @@ -149,7 +149,7 @@ let mockRecommendations: RecommendationDto[] = [ inLibrary: false, inCodex: true, status: "ongoing", - totalBookCount: 18, + totalVolumeCount: 18, rating: 85, popularity: 267000, }, @@ -167,7 +167,7 @@ let mockRecommendations: RecommendationDto[] = [ inLibrary: false, inCodex: false, status: "ongoing", - totalBookCount: 72, + totalVolumeCount: 72, rating: 86, popularity: 48000, }, @@ -185,7 +185,7 @@ let mockRecommendations: RecommendationDto[] = [ inLibrary: true, inCodex: false, status: "ended", - totalBookCount: 72, + totalVolumeCount: 72, rating: 79, popularity: 293000, }, @@ -203,7 +203,7 @@ let mockRecommendations: RecommendationDto[] = [ inLibrary: false, inCodex: false, status: "ended", - totalBookCount: 74, + totalVolumeCount: 74, rating: 76, popularity: 187000, }, @@ -222,7 +222,7 @@ let mockRecommendations: RecommendationDto[] = [ inLibrary: false, inCodex: true, status: "hiatus", - totalBookCount: 37, + totalVolumeCount: 37, rating: 90, popularity: 215000, }, @@ -240,7 +240,7 @@ let mockRecommendations: RecommendationDto[] = [ inLibrary: true, inCodex: false, status: "ended", - totalBookCount: 31, + totalVolumeCount: 31, rating: 88, popularity: 72000, }, @@ -258,7 +258,7 @@ let mockRecommendations: RecommendationDto[] = [ inLibrary: false, inCodex: false, status: "ended", - totalBookCount: 18, + totalVolumeCount: 18, rating: 92, popularity: 98000, }, @@ -277,7 +277,7 @@ let mockRecommendations: RecommendationDto[] = [ inCodex: true, codexSeriesId: "mock-dn-series-id", status: "ended", - totalBookCount: 12, + totalVolumeCount: 12, rating: 85, popularity: 310000, }, diff --git a/web/src/mocks/handlers/series.ts b/web/src/mocks/handlers/series.ts index 059d29cf..16713fb8 100644 --- a/web/src/mocks/handlers/series.ts +++ b/web/src/mocks/handlers/series.ts @@ -649,7 +649,8 @@ export const seriesHandlers = [ status, readingDirection, titleSort: seriesItem.title.toLowerCase().replace(/^the\s+/, ""), - totalBookCount: seriesItem.bookCount, + totalVolumeCount: seriesItem.bookCount, + totalChapterCount: null, year: seriesItem.year, customMetadata, createdAt: seriesItem.createdAt, @@ -672,7 +673,8 @@ export const seriesHandlers = [ status: false, readingDirection: false, titleSort: false, - totalBookCount: false, + totalVolumeCount: false, + totalChapterCount: false, year: false, genres: false, tags: false, @@ -739,7 +741,8 @@ export const seriesHandlers = [ readingDirection: body.readingDirection ?? (isJapanese ? "rtl" : "ltr"), titleSort: body.titleSort ?? seriesItem.title.toLowerCase().replace(/^the\s+/, ""), - totalBookCount: body.totalBookCount ?? seriesItem.bookCount, + totalVolumeCount: body.totalVolumeCount ?? seriesItem.bookCount, + totalChapterCount: body.totalChapterCount ?? null, year: body.year ?? seriesItem.year, customMetadata: body.customMetadata ?? customMetadata, updatedAt: new Date().toISOString(), @@ -767,7 +770,8 @@ export const seriesHandlers = [ status: false, readingDirection: false, titleSort: false, - totalBookCount: false, + totalVolumeCount: false, + totalChapterCount: false, year: false, genres: false, tags: false, @@ -800,7 +804,8 @@ export const seriesHandlers = [ status: body.status ?? false, readingDirection: body.readingDirection ?? false, titleSort: body.titleSort ?? false, - totalBookCount: body.totalBookCount ?? false, + totalVolumeCount: body.totalVolumeCount ?? false, + totalChapterCount: body.totalChapterCount ?? false, year: body.year ?? false, genres: body.genres ?? false, tags: body.tags ?? false, @@ -837,7 +842,8 @@ export const seriesHandlers = [ status: body.status ?? false, readingDirection: body.readingDirection ?? false, titleSort: body.titleSort ?? false, - totalBookCount: body.totalBookCount ?? false, + totalVolumeCount: body.totalVolumeCount ?? false, + totalChapterCount: body.totalChapterCount ?? false, year: body.year ?? false, genres: body.genres ?? false, tags: body.tags ?? false, @@ -1269,7 +1275,8 @@ export const seriesHandlers = [ readingDirection: body.readingDirection ?? "ltr", titleSort: body.titleSort ?? seriesItem.title.toLowerCase().replace(/^the\s+/, ""), - totalBookCount: body.totalBookCount ?? seriesItem.bookCount, + totalVolumeCount: body.totalVolumeCount ?? seriesItem.bookCount, + totalChapterCount: body.totalChapterCount ?? null, year: body.year ?? seriesItem.year, customMetadata: body.customMetadata ?? null, updatedAt: new Date().toISOString(), @@ -1567,7 +1574,8 @@ function toFullSeriesResponse(seriesItem: (typeof mockSeries)[number]) { language, status, readingDirection, - totalBookCount: seriesItem.bookCount, + totalVolumeCount: seriesItem.bookCount, + totalChapterCount: null, year: seriesItem.year, customMetadata, locks: { @@ -1580,7 +1588,8 @@ function toFullSeriesResponse(seriesItem: (typeof mockSeries)[number]) { statusLock: false, readingDirectionLock: false, titleSortLock: false, - totalBookCountLock: false, + totalVolumeCountLock: false, + totalChapterCountLock: false, yearLock: false, genresLock: false, tagsLock: false, diff --git a/web/src/utils/templateUtils.test.ts b/web/src/utils/templateUtils.test.ts index a6c06b7a..e619bf9f 100644 --- a/web/src/utils/templateUtils.test.ts +++ b/web/src/utils/templateUtils.test.ts @@ -28,7 +28,8 @@ function createMockMetadata( language: null, status: null, readingDirection: null, - totalBookCount: null, + totalVolumeCount: null, + totalChapterCount: null, titleSort: null, genres: [], tags: [], @@ -46,7 +47,8 @@ function createMockMetadata( ageRating: false, year: false, status: false, - totalBookCount: false, + totalVolumeCount: false, + totalChapterCount: false, readingDirection: false, customMetadata: false, genres: false, @@ -74,7 +76,7 @@ describe("templateUtils", () => { language: "ja", status: "ended", readingDirection: "rtl", - totalBookCount: 34, + totalVolumeCount: 34, titleSort: "Attack on Titan", }); @@ -89,7 +91,7 @@ describe("templateUtils", () => { expect(result.language).toBe("ja"); expect(result.status).toBe("ended"); expect(result.readingDirection).toBe("rtl"); - expect(result.totalBookCount).toBe(34); + expect(result.totalVolumeCount).toBe(34); expect(result.titleSort).toBe("Attack on Titan"); }); @@ -111,7 +113,8 @@ describe("templateUtils", () => { expect(result.language).toBeNull(); expect(result.status).toBeNull(); expect(result.readingDirection).toBeNull(); - expect(result.totalBookCount).toBeNull(); + expect(result.totalVolumeCount).toBeNull(); + expect(result.totalChapterCount).toBeNull(); expect(result.titleSort).toBeNull(); }); @@ -431,7 +434,8 @@ describe("templateUtils", () => { language: null, status: null, readingDirection: null, - totalBookCount: null, + totalVolumeCount: null, + totalChapterCount: null, titleSort: null, customMetadata: null, locks: { @@ -444,7 +448,8 @@ describe("templateUtils", () => { ageRating: false, year: false, status: false, - totalBookCount: false, + totalVolumeCount: false, + totalChapterCount: false, readingDirection: false, customMetadata: false, genres: false, @@ -476,7 +481,7 @@ describe("templateUtils", () => { language: "ja", status: "ended", readingDirection: "rtl", - totalBookCount: 34, + totalVolumeCount: 34, titleSort: "Attack on Titan", }, ); @@ -492,7 +497,7 @@ describe("templateUtils", () => { expect(result.language).toBe("ja"); expect(result.status).toBe("ended"); expect(result.readingDirection).toBe("rtl"); - expect(result.totalBookCount).toBe(34); + expect(result.totalVolumeCount).toBe(34); expect(result.titleSort).toBe("Attack on Titan"); }); @@ -723,13 +728,15 @@ describe("templateUtils", () => { expect("titleSort" in metadata).toBe(true); expect("ageRating" in metadata).toBe(true); expect("readingDirection" in metadata).toBe(true); - expect("totalBookCount" in metadata).toBe(true); + expect("totalVolumeCount" in metadata).toBe(true); + expect("totalChapterCount" in metadata).toBe(true); // Should NOT have snake_case versions expect("title_sort" in metadata).toBe(false); expect("age_rating" in metadata).toBe(false); expect("reading_direction" in metadata).toBe(false); - expect("total_book_count" in metadata).toBe(false); + expect("total_volume_count" in metadata).toBe(false); + expect("total_chapter_count" in metadata).toBe(false); }); it("should have metadata lock fields with camelCase names", () => { @@ -743,7 +750,8 @@ describe("templateUtils", () => { expect("summaryLock" in metadata).toBe(true); expect("ageRatingLock" in metadata).toBe(true); expect("readingDirectionLock" in metadata).toBe(true); - expect("totalBookCountLock" in metadata).toBe(true); + expect("totalVolumeCountLock" in metadata).toBe(true); + expect("totalChapterCountLock" in metadata).toBe(true); expect("genresLock" in metadata).toBe(true); expect("tagsLock" in metadata).toBe(true); expect("customMetadataLock" in metadata).toBe(true); @@ -834,7 +842,8 @@ describe("templateUtils", () => { expect(metadata.languageLock).toBe(false); expect(metadata.readingDirectionLock).toBe(false); expect(metadata.yearLock).toBe(false); - expect(metadata.totalBookCountLock).toBe(false); + expect(metadata.totalVolumeCountLock).toBe(false); + expect(metadata.totalChapterCountLock).toBe(false); expect(metadata.genresLock).toBe(false); expect(metadata.tagsLock).toBe(false); expect(metadata.customMetadataLock).toBe(false); @@ -1179,7 +1188,8 @@ describe("templateUtils", () => { ageRating: null, year: null, status: null, - totalBookCount: null, + totalVolumeCount: null, + totalChapterCount: null, readingDirection: null, customMetadata: null, authors: null, @@ -1193,7 +1203,8 @@ describe("templateUtils", () => { ageRating: false, year: false, status: false, - totalBookCount: false, + totalVolumeCount: false, + totalChapterCount: false, readingDirection: false, customMetadata: false, genres: false, diff --git a/web/src/utils/templateUtils.ts b/web/src/utils/templateUtils.ts index fda16164..2e3cb048 100644 --- a/web/src/utils/templateUtils.ts +++ b/web/src/utils/templateUtils.ts @@ -81,7 +81,8 @@ export const SAMPLE_SERIES_CONTEXT: SeriesContextWithCustomMetadata = { language: "ja", readingDirection: "rtl", year: 1997, - totalBookCount: 110, + totalVolumeCount: 110, + totalChapterCount: 1086.5, genres: ["Action", "Adventure", "Comedy", "Fantasy"], tags: ["pirates", "treasure", "friendship", "manga"], alternateTitles: [ @@ -116,7 +117,8 @@ export const SAMPLE_SERIES_CONTEXT: SeriesContextWithCustomMetadata = { languageLock: false, readingDirectionLock: false, yearLock: false, - totalBookCountLock: false, + totalVolumeCountLock: false, + totalChapterCountLock: false, genresLock: false, tagsLock: false, customMetadataLock: false, @@ -189,7 +191,8 @@ export function transformFullSeriesToSeriesContext( language: metadata.language ?? null, readingDirection: metadata.readingDirection ?? null, year: metadata.year ?? null, - totalBookCount: metadata.totalBookCount ?? null, + totalVolumeCount: metadata.totalVolumeCount ?? null, + totalChapterCount: metadata.totalChapterCount ?? null, genres: series.genres.map((g) => g.name), tags: series.tags.map((t) => t.name), alternateTitles: series.alternateTitles.map((at) => ({ @@ -221,7 +224,8 @@ export function transformFullSeriesToSeriesContext( languageLock: metadata.locks.language ?? false, readingDirectionLock: metadata.locks.readingDirection ?? false, yearLock: metadata.locks.year ?? false, - totalBookCountLock: metadata.locks.totalBookCount ?? false, + totalVolumeCountLock: metadata.locks.totalVolumeCount ?? false, + totalChapterCountLock: metadata.locks.totalChapterCount ?? false, genresLock: metadata.locks.genres ?? false, tagsLock: metadata.locks.tags ?? false, customMetadataLock: metadata.locks.customMetadata ?? false, @@ -513,8 +517,10 @@ export interface MetadataForTemplate { status: string | null; /** Reading direction (ltr, rtl, ttb, webtoon) */ readingDirection: string | null; - /** Expected total book count */ - totalBookCount: number | null; + /** Expected total volume count, when known */ + totalVolumeCount: number | null; + /** Expected total chapter count, when known. May be fractional. */ + totalChapterCount: number | null; /** Custom sort name */ titleSort: string | null; /** Genre names as a simple array of strings */ @@ -544,7 +550,8 @@ export const SAMPLE_METADATA_FOR_TEMPLATE: MetadataForTemplate = { language: "ja", status: "ended", readingDirection: "rtl", - totalBookCount: 34, + totalVolumeCount: 34, + totalChapterCount: 139, titleSort: "Attack on Titan", genres: ["Action", "Dark Fantasy", "Post-Apocalyptic"], tags: ["manga", "titans", "survival", "military"], @@ -593,7 +600,8 @@ export function transformToMetadataForTemplate( language: metadata.language ?? null, status: metadata.status ?? null, readingDirection: metadata.readingDirection ?? null, - totalBookCount: metadata.totalBookCount ?? null, + totalVolumeCount: metadata.totalVolumeCount ?? null, + totalChapterCount: metadata.totalChapterCount ?? null, titleSort: metadata.titleSort ?? null, // Simplify genres to just names @@ -652,7 +660,8 @@ export function transformFullSeriesToMetadataForTemplate( language: metadata.language ?? null, status: metadata.status ?? null, readingDirection: metadata.readingDirection ?? null, - totalBookCount: metadata.totalBookCount ?? null, + totalVolumeCount: metadata.totalVolumeCount ?? null, + totalChapterCount: metadata.totalChapterCount ?? null, titleSort: metadata.titleSort ?? null, // Arrays from top-level of FullSeriesResponse From 25a8b3330edb83c31f196bbd552bfe2a3781e7f2 Mon Sep 17 00:00:00 2001 From: Sylvain Cau Date: Fri, 1 May 2026 19:12:57 -0700 Subject: [PATCH 11/19] docs: document split volume and chapter counts for series metadata Add the user-facing and plugin-author documentation for the metadata count split (total_volume_count + total_chapter_count replacing the legacy total_book_count). User docs: - New series-metadata.md page covering volumes vs. chapters semantics, the four total-display variants, manual-edit instructions with per-field locks, what each first-party plugin populates, migration notes, and the Komga compatibility caveat. Wired into the docs sidebar between book-metadata and custom-metadata. - Sweep stale totalBookCount references in custom-metadata.md (field table + Handlebars examples), preprocessing-rules.md (field table), and plugins/anilist-sync.md (Completed-status criterion). Plugin author guide: - Add a "Volume and Chapter Counts" subsection to writing-plugins.md with a field-comparison table, mapping examples for MangaBaka, AniList, and volume-only providers, and a protocol-1.2 deprecation note. Update the example provider's get() to populate both fields. - Replace totalBookCount with totalVolumeCount and totalChapterCount on the PluginSeriesMetadata interface in sdk.md and on the metadata/series/get example response in protocol.md. Changelog: - Prepend an [unreleased] block calling out the breaking API DTO change, the plugin-protocol 1.2 bump, and the Handlebars template context change, plus the additive features (per-axis split + the search-result format discriminator). --- docs/dev/plugins/protocol.md | 3 +- docs/dev/plugins/sdk.md | 12 +++- docs/dev/plugins/writing-plugins.md | 44 +++++++++++++++ docs/docs/custom-metadata.md | 9 +-- docs/docs/plugins/anilist-sync.md | 2 +- docs/docs/preprocessing-rules.md | 3 +- docs/docs/series-metadata.md | 87 +++++++++++++++++++++++++++++ docs/sidebars.ts | 1 + 8 files changed, 153 insertions(+), 8 deletions(-) create mode 100644 docs/docs/series-metadata.md diff --git a/docs/dev/plugins/protocol.md b/docs/dev/plugins/protocol.md index 25d457e8..269f63cf 100644 --- a/docs/dev/plugins/protocol.md +++ b/docs/dev/plugins/protocol.md @@ -217,7 +217,8 @@ Get full metadata for an external ID. "summary": "A long, epic pirate adventure...", "status": "ongoing", "year": 1997, - "totalBookCount": 108, + "totalVolumeCount": 108, + "totalChapterCount": 1100.5, "language": "ja", "readingDirection": "rtl", "genres": ["Action", "Adventure", "Comedy"], diff --git a/docs/dev/plugins/sdk.md b/docs/dev/plugins/sdk.md index a9c47852..47f5cdfc 100644 --- a/docs/dev/plugins/sdk.md +++ b/docs/dev/plugins/sdk.md @@ -406,7 +406,17 @@ interface PluginSeriesMetadata { summary?: string; status?: SeriesStatus; year?: number; - totalBookCount?: number; + /** + * Expected total number of volumes in the series (integer). + * Populate from upstream when available; leave undefined otherwise. + */ + totalVolumeCount?: number; + /** + * Expected total number of chapters in the series (decimal). + * Supports fractional chapters (e.g. `47.5`, `100.5`). + * Populate from upstream when available; leave undefined otherwise. + */ + totalChapterCount?: number; language?: string; ageRating?: number; readingDirection?: ReadingDirection; diff --git a/docs/dev/plugins/writing-plugins.md b/docs/dev/plugins/writing-plugins.md index 28e017df..b3b858ab 100644 --- a/docs/dev/plugins/writing-plugins.md +++ b/docs/dev/plugins/writing-plugins.md @@ -229,6 +229,10 @@ const provider: MetadataProvider = { summary: item.description, status: item.status, year: item.year, + // Populate volume and chapter totals separately (see "Volume and + // Chapter Counts" below for guidance). + totalVolumeCount: item.volumeCount, + totalChapterCount: item.chapterCount, genres: item.genres, tags: item.tags, authors: item.authors, @@ -306,6 +310,46 @@ throw new RateLimitError(60, "Rate limited by external API"); throw new AuthError("Invalid API key"); ``` +#### Volume and Chapter Counts + +`PluginSeriesMetadata` carries two independent count fields. Populate whichever ones your upstream API exposes; leave the other(s) undefined. + +| Field | Type | When to populate | +|-------|------|------------------| +| `totalVolumeCount` | integer | Upstream knows the expected number of bound volumes. | +| `totalChapterCount` | number (decimal) | Upstream knows the expected number of chapters. Decimals are allowed (e.g. `47.5` for an omake chapter). | + +These map directly onto Codex's per-axis lock model: a user can lock the volume count while letting the chapter count refresh, or vice versa. Codex's apply pipeline checks the corresponding lock and the corresponding write permission (`metadata:write:total_volume_count`, `metadata:write:total_chapter_count`) before writing each field. + +Mapping common upstream shapes: + +```typescript +// MangaBaka-style: distinct fields on the response. +return { + // ... + totalVolumeCount: upstream.totalVolumes ?? undefined, + totalChapterCount: upstream.totalChapters ?? undefined, +}; + +// AniList GraphQL: `volumes` and `chapters` on the Media node. +return { + // ... + totalVolumeCount: media.volumes ?? undefined, + totalChapterCount: media.chapters ?? undefined, +}; + +// Volume-only provider (e.g. Open Library): leave chapters undefined. +return { + // ... + totalVolumeCount: edition.volumeCount ?? undefined, + // totalChapterCount intentionally omitted +}; +``` + +If the upstream returns a single ambiguous "book count" without telling you whether it means volumes or chapters, prefer mapping it to `totalVolumeCount` (matches today's convention for most metadata providers). Do not populate both fields with the same number; Codex treats them as independent values, and seeding both will produce nonsense like "14 vol · 14 ch" in the UI. + +The legacy `totalBookCount` field on `PluginSeriesMetadata` was removed in protocol version 1.2. Plugins emitting it on the wire will not error (the value is silently dropped), but neither count field is populated as a result. Update your mapper to emit `totalVolumeCount` and/or `totalChapterCount` instead. + ### 5. Build and Test Locally Build the plugin: diff --git a/docs/docs/custom-metadata.md b/docs/docs/custom-metadata.md index 8215bbd4..8b180ba1 100644 --- a/docs/docs/custom-metadata.md +++ b/docs/docs/custom-metadata.md @@ -242,7 +242,8 @@ When `type` is `"series"`, the `metadata` object contains: | `metadata.imprint` | string | Publisher imprint | | `metadata.year` | number | Publication year | | `metadata.status` | string | Series status (e.g., "ongoing", "completed") | -| `metadata.totalBookCount` | number | Total number of books in the series | +| `metadata.totalVolumeCount` | number | Expected total volume count (integer) | +| `metadata.totalChapterCount` | number | Expected total chapter count (decimal; supports fractional chapters like `47.5`) | | `metadata.ageRating` | number | Age rating (e.g., 13, 18) | | `metadata.language` | string | Primary language | | `metadata.readingDirection` | string | Reading direction (ltr, rtl, ttb) | @@ -319,7 +320,7 @@ Use the `type` discriminator to create templates that work for both series and b {{#ifEquals type "series"}} ## {{metadata.title}} -**Books:** {{bookCount}}{{#if metadata.totalBookCount}} of {{metadata.totalBookCount}}{{/if}} +**Books:** {{bookCount}}{{#if metadata.totalVolumeCount}} of {{metadata.totalVolumeCount}} vol{{/if}}{{#if metadata.totalChapterCount}} ({{metadata.totalChapterCount}} ch){{/if}} {{#if metadata.status}} **Status:** {{capitalize metadata.status}} {{/if}} @@ -392,7 +393,7 @@ Use the `type` discriminator to create templates that work for both series and b **Status:** {{default customMetadata.status "Not started"}} {{#if customMetadata.currentVolume}} -**Currently on:** Volume {{customMetadata.currentVolume}}{{#if metadata.totalBookCount}} of {{metadata.totalBookCount}}{{/if}} +**Currently on:** Volume {{customMetadata.currentVolume}}{{#if metadata.totalVolumeCount}} of {{metadata.totalVolumeCount}}{{/if}} {{/if}} {{/if}} ``` @@ -705,7 +706,7 @@ Combine custom tracking data with built-in series metadata: {{/if}} {{#if customMetadata.currentVolume}} -**Currently on:** Volume {{customMetadata.currentVolume}}{{#if metadata.totalBookCount}} of {{metadata.totalBookCount}}{{/if}} +**Currently on:** Volume {{customMetadata.currentVolume}}{{#if metadata.totalVolumeCount}} of {{metadata.totalVolumeCount}}{{/if}} {{/if}} {{#if customMetadata.notes}} diff --git a/docs/docs/plugins/anilist-sync.md b/docs/docs/plugins/anilist-sync.md index 4b9d38fa..e21e0ded 100644 --- a/docs/docs/plugins/anilist-sync.md +++ b/docs/docs/plugins/anilist-sync.md @@ -102,7 +102,7 @@ If you want to manually manage your AniList list without Codex overwriting it, u The plugin is conservative about marking series as "Completed" on AniList: -- A series is pushed as **Completed** only when **all** local books are read **and** the series metadata includes a `total_book_count` that matches. +- A series is pushed as **Completed** only when **all** local books are read **and** the series metadata includes a `total_volume_count` (or `total_chapter_count`, depending on the configured Progress Unit) that matches the local count. - Otherwise, the series is always pushed as **Reading** — even if all local books are read — because Codex can't be certain the library contains the full series. This prevents incorrectly marking a series as finished when you may simply not have all volumes in your library yet. diff --git a/docs/docs/preprocessing-rules.md b/docs/docs/preprocessing-rules.md index 0aa3c371..e3ed63e8 100644 --- a/docs/docs/preprocessing-rules.md +++ b/docs/docs/preprocessing-rules.md @@ -373,7 +373,8 @@ External ID sources use the format `plugin:` (e.g., `plugin:mangabaka`). | `metadata.imprint` | string | Publisher imprint | | `metadata.ageRating` | number | Age rating | | `metadata.readingDirection` | string | Reading direction (ltr/rtl) | -| `metadata.totalBookCount` | number | Expected total book count | +| `metadata.totalVolumeCount` | number | Expected total volume count (integer) | +| `metadata.totalChapterCount` | number | Expected total chapter count (decimal) | | `metadata.genres` | array | Genre names | | `metadata.tags` | array | Tag names | diff --git a/docs/docs/series-metadata.md b/docs/docs/series-metadata.md new file mode 100644 index 00000000..054e4b0c --- /dev/null +++ b/docs/docs/series-metadata.md @@ -0,0 +1,87 @@ +--- +--- + +# Series Metadata + +Codex stores rich metadata at the **series** level: title, summary, status, publisher, ratings, alternate titles, external IDs, and **expected total counts**. This guide focuses on the count fields, since they are the most commonly misunderstood, and explains the manual edit form, locks, and how counts interact with metadata refresh. + +## Volumes vs. Chapters + +Different libraries organize their content differently, and Codex models this directly with **two separate count fields** rather than a single ambiguous "book count": + +| Field | Type | Description | +|-------|------|-------------| +| **Total Volumes** | integer | Expected number of volumes (e.g., a 14-volume manga). | +| **Total Chapters** | decimal | Expected number of chapters (e.g., 142.5). Fractional values are allowed for special chapters like 47.5 or 100.5. | + +Both fields are optional and independent. A series can have: + +- **Only a volume count**: typical for volume-organized libraries (one file = one volume). Example: a complete 14-volume manga shows `14 vol`. +- **Only a chapter count**: typical for chapter-organized libraries (one file = one chapter). Example: an ongoing series with no volume releases shows `142 ch`. +- **Both**: typical for mixed libraries that own bound volumes plus loose chapters that haven't been collected yet. Example: `14 vol · 142 ch`. +- **Neither**: for series where the expected total isn't known. + +The series detail header shows whichever totals are populated: + +- **Both totals known**: `109/14 vol · 142 ch` +- **Volume only**: `14/14 vol` +- **Chapter only**: `109/142 ch` +- **Neither**: just the local book count. + +The number on the **left** of `/` is your local book count; the number on the **right** is the expected total from metadata. + +:::tip Mixed libraries +If you keep volumes 1-14 plus loose chapters 126-142 in the same series folder, set Total Volumes to 14 and Total Chapters to 142. Codex will track and display both axes correctly. +::: + +## Editing Counts Manually + +Open the series detail page, click the metadata edit button, and find the **Total Volumes** and **Total Chapters** inputs: + +- **Total Volumes**: integer input. Leave empty if unknown. +- **Total Chapters**: decimal input. Accepts values like `142.5`. Leave empty if unknown. + +Each field has its own **lock toggle** next to it. Locking a field tells metadata refresh to skip it; the value will not be overwritten by plugins. + +## Locks and Metadata Refresh + +When a metadata refresh runs, plugins propose values for both count fields. Codex applies them only if: + +1. The plugin has the corresponding write permission (`metadata:write:total_volume_count` or `metadata:write:total_chapter_count`). +2. The corresponding lock is **off**. +3. The plugin actually returned a value for that field. + +This means you can: + +- **Lock the volume count** on a finished series whose volumes won't change, while letting the chapter count refresh as new chapters publish. +- **Lock the chapter count** if a provider's chapter total is wrong and you've manually corrected it, while still receiving volume updates. +- **Lock both** to freeze the displayed totals entirely. + +## What Plugins Provide + +Codex's first-party plugins populate both counts where the upstream API exposes them: + +- **MangaBaka**: populates both Total Volumes and Total Chapters when the series page lists them. Also populates the search-result format badge (Manga / Novel / Manhwa / etc.) so visually-identical results are distinguishable in the metadata search modal. +- **AniList**: populates both Total Volumes and Total Chapters from AniList GraphQL. +- **Open Library**: populates Total Volumes only (Open Library does not expose chapter counts). + +Other metadata-source plugins follow the same shape; see the [Plugin Author Guide](dev/plugins/writing-plugins.md) for what to populate. + +## Migration from `totalBookCount` + +Codex used to expose a single `totalBookCount` field. It is now replaced by `totalVolumeCount` and `totalChapterCount` (separate fields, separate locks). + +On upgrade: + +- Existing values were migrated to **Total Volumes** (since most pre-existing data was volume-shaped in practice). +- Existing locks were transferred to the **Total Volumes** lock. +- **Total Chapters** starts empty for every series. Run a metadata refresh against MangaBaka or AniList to populate it. +- The legacy `totalBookCount` field is removed from the API and template context. References in custom-metadata templates and preprocessing rules must be updated to `totalVolumeCount` and/or `totalChapterCount`. + +:::caution Komga API compatibility +The Komga compatibility layer (`/{prefix}/api/v1/series/...`) continues to expose `totalBookCount` on the wire so Komga clients (e.g. Komic for iOS) keep working. The value sent over the wire comes from `totalVolumeCount` (the closest semantic match to Komga's existing field). Chapter counts are not exposed via the Komga compatibility layer because Komga itself has no equivalent field. +::: + +## Other Series Metadata + +Beyond counts, series carry standard metadata fields: title, summary, status (`ongoing`, `ended`, `hiatus`, `abandoned`, `unknown`), publication year, language, reading direction, genres, tags, authors, publisher, age rating, ratings, alternate titles, and external links. All of these are editable in the same modal and support per-field locks via the same lock toggle pattern. diff --git a/docs/sidebars.ts b/docs/sidebars.ts index fe4371d5..aeb2a3f2 100644 --- a/docs/sidebars.ts +++ b/docs/sidebars.ts @@ -36,6 +36,7 @@ const sidebars: SidebarsConfig = { "formats", "filtering", "book-metadata", + "series-metadata", "custom-metadata", { type: "category", From a3b5c5fe8236eedeb043ccd3407d362b82eec37e Mon Sep 17 00:00:00 2001 From: Sylvain Cau Date: Fri, 1 May 2026 21:16:44 -0700 Subject: [PATCH 12/19] feat(metadata): add per-book volume and chapter classification Adds the local counterpart to the world totals (total_volume_count / total_chapter_count) by splitting per-book classification into sibling volume and chapter columns on book_metadata. The populated/null pairing across the two columns derives the kind of book (volume / chapter / chapter-of-volume / unknown) without an enum, enabling the local_max_volume / local_max_chapter aggregations release-tracking needs. - Migration adds chapter (REAL) + chapter_lock (BOOLEAN) to book_metadata with up/down coverage. - Entity gains chapter + chapter_lock; BookMetadataRepository gains update_chapter, update_volume, and a set_lock(field, locked) helper mirroring the series_metadata pattern. - Renames the BookNamingStrategy trait to BookMetadataStrategy (alias retained) and extends it with resolve_volume / resolve_chapter. Each strategy implements per its semantics: Filename uses a structured regex, MetadataFirst reads ComicInfo only, Smart prefers ComicInfo with a filename fallback, SeriesName passes through context, and Custom delegates to its existing named-group extraction. - New structured filename parser uses a lenient prefix (v / vol / volume, c / ch / chapter), strict left boundary, first-match-wins per axis, no bare-number fallback. Fractional volumes are rejected (column is i32) and fractional chapters are preserved (e.g. 47.5 side chapters). - Scanner wires the classification into both metadata-write paths so the active strategy populates volume + chapter on every analysis, with per-field locks gating writes the same way they gate every other field. Tests added at the migration, repository, strategy, and parser layers. --- migration/src/lib.rs | 4 + .../src/m20260503_000069_add_book_chapter.rs | 72 +++++++ src/api/routes/v1/handlers/books.rs | 6 + src/db/entities/book_metadata.rs | 7 + src/db/repositories/metadata.rs | 177 ++++++++++++++++ src/scanner/analyzer_queue.rs | 108 +++++++++- src/scanner/strategies/book/custom.rs | 33 +++ src/scanner/strategies/book/filename.rs | 190 +++++++++++++++++- src/scanner/strategies/book/metadata_first.rs | 27 +++ src/scanner/strategies/book/mod.rs | 68 ++++++- src/scanner/strategies/book/series_name.rs | 33 +++ src/scanner/strategies/book/smart.rs | 47 ++++- tests/api/komga.rs | 6 + tests/db/migrations.rs | 113 +++++++++++ tests/scanner/book_naming_strategy.rs | 12 ++ 15 files changed, 882 insertions(+), 21 deletions(-) create mode 100644 migration/src/m20260503_000069_add_book_chapter.rs diff --git a/migration/src/lib.rs b/migration/src/lib.rs index 32ab859d..abbe0870 100644 --- a/migration/src/lib.rs +++ b/migration/src/lib.rs @@ -139,6 +139,8 @@ mod m20260410_000066_add_export_type; mod m20260502_000067_split_book_count; // Drop legacy series_metadata.total_book_count and lock columns (Phase 9 hard removal) mod m20260502_000068_drop_book_count; +// Add chapter + chapter_lock columns to book_metadata (Phase 11 per-book classification) +mod m20260503_000069_add_book_chapter; pub struct Migrator; @@ -250,6 +252,8 @@ impl MigratorTrait for Migrator { Box::new(m20260502_000067_split_book_count::Migration), // Drop legacy total_book_count column and lock (Phase 9 hard removal) Box::new(m20260502_000068_drop_book_count::Migration), + // Add chapter + chapter_lock columns to book_metadata (Phase 11) + Box::new(m20260503_000069_add_book_chapter::Migration), ] } } diff --git a/migration/src/m20260503_000069_add_book_chapter.rs b/migration/src/m20260503_000069_add_book_chapter.rs new file mode 100644 index 00000000..4e4a75aa --- /dev/null +++ b/migration/src/m20260503_000069_add_book_chapter.rs @@ -0,0 +1,72 @@ +//! Add `chapter` and `chapter_lock` columns to `book_metadata` (Phase 11 of metadata-count-split). +//! +//! Per-book classification: `book_metadata` already has `volume Option` and +//! `volume_lock`. This migration adds the sibling `chapter Option` plus +//! `chapter_lock`. The combination of populated/null values across the two +//! columns derives the kind of book (volume / chapter / chapter-of-volume / +//! unknown) without needing an explicit enum. +//! +//! Why REAL (f32): chapter numbers in manga frequently include decimals +//! (e.g. 47.5 for "side chapter"). Matches `series_metadata.total_chapter_count` +//! and `series_tracking.latest_known_chapter`, both REAL. + +use sea_orm_migration::prelude::*; + +use crate::m20260103_000014_create_book_metadata::BookMetadata; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + // Add chapter (REAL/FLOAT NULL). + manager + .alter_table( + Table::alter() + .table(BookMetadata::Table) + .add_column(ColumnDef::new(Alias::new("chapter")).float()) + .to_owned(), + ) + .await?; + + // Add chapter_lock (BOOLEAN NOT NULL DEFAULT FALSE). + manager + .alter_table( + Table::alter() + .table(BookMetadata::Table) + .add_column( + ColumnDef::new(Alias::new("chapter_lock")) + .boolean() + .not_null() + .default(false), + ) + .to_owned(), + ) + .await?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .alter_table( + Table::alter() + .table(BookMetadata::Table) + .drop_column(Alias::new("chapter_lock")) + .to_owned(), + ) + .await?; + + manager + .alter_table( + Table::alter() + .table(BookMetadata::Table) + .drop_column(Alias::new("chapter")) + .to_owned(), + ) + .await?; + + Ok(()) + } +} diff --git a/src/api/routes/v1/handlers/books.rs b/src/api/routes/v1/handlers/books.rs index 66a6dc5f..e9c3e53c 100644 --- a/src/api/routes/v1/handlers/books.rs +++ b/src/api/routes/v1/handlers/books.rs @@ -1213,6 +1213,7 @@ pub async fn patch_book( month: Set(None), day: Set(None), volume: Set(None), + chapter: Set(None), count: Set(None), isbns: Set(None), // New Phase 1 fields @@ -1244,6 +1245,7 @@ pub async fn patch_book( month_lock: Set(false), day_lock: Set(false), volume_lock: Set(false), + chapter_lock: Set(false), count_lock: Set(false), isbns_lock: Set(false), // New Phase 1 lock fields @@ -2176,6 +2178,7 @@ pub async fn replace_book_metadata( month: Set(request.month), day: Set(request.day), volume: Set(request.volume), + chapter: Set(None), count: Set(request.count), isbns: Set(request.isbns.clone()), // New Phase 1 fields @@ -2207,6 +2210,7 @@ pub async fn replace_book_metadata( month_lock: Set(request.month.is_some()), day_lock: Set(request.day.is_some()), volume_lock: Set(request.volume.is_some()), + chapter_lock: Set(false), count_lock: Set(request.count.is_some()), isbns_lock: Set(request.isbns.is_some()), // New Phase 1 lock fields @@ -2763,6 +2767,7 @@ pub async fn patch_book_metadata( month: Set(month_opt), day: Set(day_opt), volume: Set(volume_opt), + chapter: Set(None), count: Set(count_opt), isbns: Set(isbns_opt.clone()), // New Phase 6 fields @@ -2796,6 +2801,7 @@ pub async fn patch_book_metadata( month_lock: Set(month_opt.is_some()), day_lock: Set(day_opt.is_some()), volume_lock: Set(volume_opt.is_some()), + chapter_lock: Set(false), count_lock: Set(count_opt.is_some()), isbns_lock: Set(isbns_opt.is_some()), // New Phase 6 lock fields diff --git a/src/db/entities/book_metadata.rs b/src/db/entities/book_metadata.rs index 4d687bba..ae7e8fae 100644 --- a/src/db/entities/book_metadata.rs +++ b/src/db/entities/book_metadata.rs @@ -129,6 +129,12 @@ pub struct Model { pub month: Option, pub day: Option, pub volume: Option, + /// Chapter number for this book (sibling of `volume`). + /// `f32` because chapters can be fractional (e.g. 47.5 for side chapters). + /// The pair `(volume, chapter)` derives the kind of book: + /// `(Some, None)` = volume, `(None, Some)` = chapter, + /// `(Some, Some)` = chapter belonging to a volume, `(None, None)` = unknown. + pub chapter: Option, pub count: Option, pub isbns: Option, // New book metadata fields (Phase 1) @@ -174,6 +180,7 @@ pub struct Model { pub month_lock: bool, pub day_lock: bool, pub volume_lock: bool, + pub chapter_lock: bool, pub count_lock: bool, pub isbns_lock: bool, // New lock fields for Phase 1 fields diff --git a/src/db/repositories/metadata.rs b/src/db/repositories/metadata.rs index 82df861f..d1d68016 100644 --- a/src/db/repositories/metadata.rs +++ b/src/db/repositories/metadata.rs @@ -45,6 +45,7 @@ impl BookMetadataRepository { month: Set(metadata_model.month), day: Set(metadata_model.day), volume: Set(metadata_model.volume), + chapter: Set(metadata_model.chapter), count: Set(metadata_model.count), isbns: Set(metadata_model.isbns.clone()), // New Phase 1 fields @@ -77,6 +78,7 @@ impl BookMetadataRepository { month_lock: Set(metadata_model.month_lock), day_lock: Set(metadata_model.day_lock), volume_lock: Set(metadata_model.volume_lock), + chapter_lock: Set(metadata_model.chapter_lock), count_lock: Set(metadata_model.count_lock), isbns_lock: Set(metadata_model.isbns_lock), // New Phase 1 lock fields @@ -186,6 +188,7 @@ impl BookMetadataRepository { month: Set(metadata_model.month), day: Set(metadata_model.day), volume: Set(metadata_model.volume), + chapter: Set(metadata_model.chapter), count: Set(metadata_model.count), isbns: Set(metadata_model.isbns.clone()), // New Phase 1 fields @@ -218,6 +221,7 @@ impl BookMetadataRepository { month_lock: Set(metadata_model.month_lock), day_lock: Set(metadata_model.day_lock), volume_lock: Set(metadata_model.volume_lock), + chapter_lock: Set(metadata_model.chapter_lock), count_lock: Set(metadata_model.count_lock), isbns_lock: Set(metadata_model.isbns_lock), // New Phase 1 lock fields @@ -247,6 +251,69 @@ impl BookMetadataRepository { Ok(()) } + /// Update only the `chapter` field on an existing metadata row. + /// Mirrors the per-field update pattern used on series_metadata. + pub async fn update_chapter( + db: &DatabaseConnection, + book_id: Uuid, + chapter: Option, + ) -> Result { + let existing = Self::get_by_book_id(db, book_id) + .await? + .ok_or_else(|| anyhow::anyhow!("Book metadata not found for book: {book_id}"))?; + + let mut active: book_metadata::ActiveModel = existing.into(); + active.chapter = Set(chapter); + active.updated_at = Set(Utc::now()); + active + .update(db) + .await + .context("Failed to update book chapter") + } + + /// Update only the `volume` field on an existing metadata row. + pub async fn update_volume( + db: &DatabaseConnection, + book_id: Uuid, + volume: Option, + ) -> Result { + let existing = Self::get_by_book_id(db, book_id) + .await? + .ok_or_else(|| anyhow::anyhow!("Book metadata not found for book: {book_id}"))?; + + let mut active: book_metadata::ActiveModel = existing.into(); + active.volume = Set(volume); + active.updated_at = Set(Utc::now()); + active + .update(db) + .await + .context("Failed to update book volume") + } + + /// Lock or unlock a specific metadata field on a book. + /// + /// Currently supports the per-book classification locks (`volume`, `chapter`). + /// Extend with additional cases as new lock-aware fields are added. + pub async fn set_lock( + db: &DatabaseConnection, + book_id: Uuid, + field: &str, + locked: bool, + ) -> Result { + let existing = Self::get_by_book_id(db, book_id) + .await? + .ok_or_else(|| anyhow::anyhow!("Book metadata not found for book: {book_id}"))?; + + let mut active: book_metadata::ActiveModel = existing.into(); + match field { + "volume" => active.volume_lock = Set(locked), + "chapter" => active.chapter_lock = Set(locked), + _ => return Err(anyhow::anyhow!("Unknown book metadata lock field: {field}")), + } + active.updated_at = Set(Utc::now()); + active.update(db).await.context("Failed to set book lock") + } + /// Delete metadata by book ID pub async fn delete_by_book_id(db: &DatabaseConnection, book_id: Uuid) -> Result<()> { BookMetadata::delete_many() @@ -285,6 +352,7 @@ impl BookMetadataRepository { month: Set(None), day: Set(None), volume: Set(None), + chapter: Set(None), count: Set(None), isbns: Set(None), // New Phase 1 fields @@ -316,6 +384,7 @@ impl BookMetadataRepository { month_lock: Set(false), day_lock: Set(false), volume_lock: Set(false), + chapter_lock: Set(false), count_lock: Set(false), isbns_lock: Set(false), // New Phase 1 lock fields @@ -421,6 +490,7 @@ mod tests { month: None, day: None, volume: None, + chapter: None, count: None, isbns: None, // New Phase 1 fields @@ -452,6 +522,7 @@ mod tests { month_lock: false, day_lock: false, volume_lock: false, + chapter_lock: false, count_lock: false, isbns_lock: false, // New Phase 1 lock fields @@ -650,4 +721,110 @@ mod tests { // Others should still be false assert!(!retrieved.publisher_lock); } + + // -- Per-book volume/chapter classification (Phase 11) -- + + #[tokio::test] + async fn test_chapter_round_trip_fractional() { + let (db, _temp_dir) = create_test_db().await; + let book = create_test_book(&db).await; + + let mut metadata = create_metadata_model(book.id); + metadata.volume = Some(15); + metadata.chapter = Some(126.5); + BookMetadataRepository::upsert(db.sea_orm_connection(), &metadata) + .await + .unwrap(); + + let retrieved = BookMetadataRepository::get_by_book_id(db.sea_orm_connection(), book.id) + .await + .unwrap() + .unwrap(); + assert_eq!(retrieved.volume, Some(15)); + assert_eq!(retrieved.chapter, Some(126.5)); + assert!(!retrieved.chapter_lock); + assert!(!retrieved.volume_lock); + } + + #[tokio::test] + async fn test_update_chapter_clears_to_null() { + let (db, _temp_dir) = create_test_db().await; + let book = create_test_book(&db).await; + + let mut metadata = create_metadata_model(book.id); + metadata.chapter = Some(42.0); + BookMetadataRepository::upsert(db.sea_orm_connection(), &metadata) + .await + .unwrap(); + + let updated = + BookMetadataRepository::update_chapter(db.sea_orm_connection(), book.id, None) + .await + .unwrap(); + assert!(updated.chapter.is_none()); + } + + #[tokio::test] + async fn test_update_volume_writes_value() { + let (db, _temp_dir) = create_test_db().await; + let book = create_test_book(&db).await; + + let metadata = create_metadata_model(book.id); + BookMetadataRepository::upsert(db.sea_orm_connection(), &metadata) + .await + .unwrap(); + + let updated = + BookMetadataRepository::update_volume(db.sea_orm_connection(), book.id, Some(7)) + .await + .unwrap(); + assert_eq!(updated.volume, Some(7)); + } + + #[tokio::test] + async fn test_set_lock_volume_and_chapter_independent() { + let (db, _temp_dir) = create_test_db().await; + let book = create_test_book(&db).await; + + let metadata = create_metadata_model(book.id); + BookMetadataRepository::upsert(db.sea_orm_connection(), &metadata) + .await + .unwrap(); + + let after_chapter_lock = + BookMetadataRepository::set_lock(db.sea_orm_connection(), book.id, "chapter", true) + .await + .unwrap(); + assert!(after_chapter_lock.chapter_lock); + assert!(!after_chapter_lock.volume_lock); + + let after_volume_lock = + BookMetadataRepository::set_lock(db.sea_orm_connection(), book.id, "volume", true) + .await + .unwrap(); + assert!(after_volume_lock.chapter_lock); + assert!(after_volume_lock.volume_lock); + + let after_unlock_chapter = + BookMetadataRepository::set_lock(db.sea_orm_connection(), book.id, "chapter", false) + .await + .unwrap(); + assert!(!after_unlock_chapter.chapter_lock); + assert!(after_unlock_chapter.volume_lock); + } + + #[tokio::test] + async fn test_set_lock_unknown_field_errors() { + let (db, _temp_dir) = create_test_db().await; + let book = create_test_book(&db).await; + + let metadata = create_metadata_model(book.id); + BookMetadataRepository::upsert(db.sea_orm_connection(), &metadata) + .await + .unwrap(); + + let result = + BookMetadataRepository::set_lock(db.sea_orm_connection(), book.id, "bogus", true).await; + assert!(result.is_err()); + } } diff --git a/src/scanner/analyzer_queue.rs b/src/scanner/analyzer_queue.rs index 2bc35583..f28636de 100644 --- a/src/scanner/analyzer_queue.rs +++ b/src/scanner/analyzer_queue.rs @@ -282,9 +282,12 @@ async fn analyze_single_book( // Resolve book number using the library's number strategy let resolved_number = resolve_book_number(db, &book, &metadata, metadata_number).await; - // Resolve book title using the library's book naming strategy - // Note: title and number are now stored in book_metadata, not books table + // Resolve book title using the library's book metadata strategy. + // Note: title and number are now stored in book_metadata, not books table. let resolved_title = resolve_book_title(db, &book, &metadata, resolved_number).await; + // Phase 11: resolve structured volume/chapter via the same strategy. + let resolved_classification = + resolve_book_classification(db, &book, &metadata, resolved_number).await; let resolved_number_decimal = resolved_number.map(|n| Decimal::from_f64_retain(n as f64).unwrap_or_default()); @@ -506,7 +509,16 @@ async fn analyze_single_book( volume: if existing.volume_lock { existing.volume } else { - comic_info.volume + // Strategy-resolved volume (filename / metadata / etc.) + // takes precedence over raw ComicInfo because the strategy + // is what the library was configured with. Falls through + // to ComicInfo for strategies that don't extract volume. + resolved_classification.volume.or(comic_info.volume) + }, + chapter: if existing.chapter_lock { + existing.chapter + } else { + resolved_classification.chapter.or(existing.chapter) }, count: if existing.count_lock { existing.count @@ -546,6 +558,7 @@ async fn analyze_single_book( month_lock: existing.month_lock, day_lock: existing.day_lock, volume_lock: existing.volume_lock, + chapter_lock: existing.chapter_lock, count_lock: existing.count_lock, isbns_lock: existing.isbns_lock, // New Phase 1 lock fields - preserve existing @@ -586,7 +599,10 @@ async fn analyze_single_book( year: comic_info.year, month: comic_info.month, day: comic_info.day, - volume: comic_info.volume, + // Strategy-resolved volume takes precedence; ComicInfo is the + // fallback for strategies that don't parse a volume. + volume: resolved_classification.volume.or(comic_info.volume), + chapter: resolved_classification.chapter, count: comic_info.count, isbns: isbns_json, // New Phase 1 fields @@ -618,6 +634,7 @@ async fn analyze_single_book( month_lock: false, day_lock: false, volume_lock: false, + chapter_lock: false, count_lock: false, isbns_lock: false, // New Phase 1 lock fields @@ -749,7 +766,16 @@ async fn analyze_single_book( year: existing.year, month: existing.month, day: existing.day, - volume: existing.volume, + volume: if existing.volume_lock { + existing.volume + } else { + resolved_classification.volume.or(existing.volume) + }, + chapter: if existing.chapter_lock { + existing.chapter + } else { + resolved_classification.chapter.or(existing.chapter) + }, count: existing.count, isbns: existing.isbns.clone(), // New Phase 1 fields - preserve existing values @@ -781,6 +807,7 @@ async fn analyze_single_book( month_lock: existing.month_lock, day_lock: existing.day_lock, volume_lock: existing.volume_lock, + chapter_lock: existing.chapter_lock, count_lock: existing.count_lock, isbns_lock: existing.isbns_lock, // New Phase 1 lock fields - preserve existing @@ -820,7 +847,8 @@ async fn analyze_single_book( year: None, month: None, day: None, - volume: None, + volume: resolved_classification.volume, + chapter: resolved_classification.chapter, count: None, isbns: None, // New Phase 1 fields @@ -851,6 +879,7 @@ async fn analyze_single_book( month_lock: false, day_lock: false, volume_lock: false, + chapter_lock: false, count_lock: false, isbns_lock: false, // New Phase 1 lock fields @@ -1051,6 +1080,69 @@ async fn analyze_single_book( Ok(()) } +/// Per-book classification output from the active book metadata strategy. +/// Phase 11: lets scanner write `book_metadata.volume` and `book_metadata.chapter` +/// alongside the title. +#[derive(Debug, Default, Clone, Copy)] +struct BookClassification { + volume: Option, + chapter: Option, +} + +/// Resolve volume + chapter using the library's book metadata strategy. +/// +/// Mirrors `resolve_book_title`: same strategy, same context, same metadata +/// inputs. Returns `BookClassification::default()` if the library or series +/// can't be located (the title fallback would do the same). +async fn resolve_book_classification( + db: &DatabaseConnection, + book: &books::Model, + file_metadata: &crate::parsers::BookMetadata, + book_number: Option, +) -> BookClassification { + let Ok(Some(library)) = LibraryRepository::get_by_id(db, book.library_id).await else { + return BookClassification::default(); + }; + + let book_strategy = library + .book_strategy + .parse::() + .unwrap_or(BookStrategy::Filename); + let book_config_str = library.book_config.as_ref().map(|v| v.to_string()); + let strategy = create_book_strategy(book_strategy, book_config_str.as_deref()); + + let series_name = match SeriesMetadataRepository::get_by_series_id(db, book.series_id).await { + Ok(Some(m)) => m.title, + _ => String::new(), + }; + + let total_books = BookRepository::count_by_series(db, book.series_id) + .await + .map(|c| c as usize) + .unwrap_or(1); + + let metadata = file_metadata.comic_info.as_ref().map(|ci| BookMetadata { + title: ci.title.clone().filter(|t| !t.is_empty()), + number: book_number, + volume: ci.volume, + // ComicInfo has no Chapter field today; Phase 12 wires one through. + chapter: None, + }); + + let context = BookNamingContext { + series_name, + book_number, + volume: None, + chapter_number: None, + total_books, + }; + + BookClassification { + volume: strategy.resolve_volume(&book.file_name, metadata.as_ref(), &context), + chapter: strategy.resolve_chapter(&book.file_name, metadata.as_ref(), &context), + } +} + /// Resolve book title using the library's book naming strategy async fn resolve_book_title( db: &DatabaseConnection, @@ -1101,6 +1193,8 @@ async fn resolve_book_title( let metadata = file_metadata.comic_info.as_ref().map(|ci| BookMetadata { title: ci.title.clone().filter(|t| !t.is_empty()), number: book_number, + volume: ci.volume, + chapter: None, }); // Build naming context @@ -1514,6 +1608,7 @@ mod tests { month: None, day: None, volume: None, + chapter: None, count: None, isbns: None, // New Phase 1 fields @@ -1545,6 +1640,7 @@ mod tests { month_lock: false, day_lock: false, volume_lock: false, + chapter_lock: false, count_lock: false, isbns_lock: false, // New Phase 1 lock fields diff --git a/src/scanner/strategies/book/custom.rs b/src/scanner/strategies/book/custom.rs index 6f62acb4..f083b98c 100644 --- a/src/scanner/strategies/book/custom.rs +++ b/src/scanner/strategies/book/custom.rs @@ -128,6 +128,37 @@ impl BookNamingStrategy for CustomStrategy { let fallback_strategy = create_book_strategy(self.fallback, None); fallback_strategy.resolve_title(file_name, metadata, context) } + + /// Volume from the user's `(?P\d+)` named group. `extract_volume` + /// returns `f32` (Custom predates Phase 11); the trait narrows to `i32` for + /// schema compat. Fractional values are rejected rather than truncated — + /// silent truncation would discard user-meaningful information. If a user + /// hits this we widen the column. + fn resolve_volume( + &self, + file_name: &str, + _metadata: Option<&BookMetadata>, + _context: &BookNamingContext, + ) -> Option { + let name = filename_without_extension(file_name); + self.extract_volume(&name).and_then(|v| { + if v.fract() == 0.0 && v >= 0.0 { + Some(v as i32) + } else { + None + } + }) + } + + fn resolve_chapter( + &self, + file_name: &str, + _metadata: Option<&BookMetadata>, + _context: &BookNamingContext, + ) -> Option { + let name = filename_without_extension(file_name); + self.extract_chapter(&name) + } } #[cfg(test)] @@ -231,6 +262,8 @@ mod tests { let metadata = BookMetadata { title: Some("The Dark Knight".to_string()), number: Some(1.0), + volume: None, + chapter: None, }; // This doesn't match the pattern, so fallback to metadata_first diff --git a/src/scanner/strategies/book/filename.rs b/src/scanner/strategies/book/filename.rs index eda8c63f..6c21d999 100644 --- a/src/scanner/strategies/book/filename.rs +++ b/src/scanner/strategies/book/filename.rs @@ -1,10 +1,37 @@ -//! Filename book naming strategy +//! Filename book metadata strategy //! -//! Always uses the filename without extension (Komga-compatible) +//! Always uses the filename without extension for the title (Komga-compatible). +//! Phase 11: also extracts structured volume/chapter numbers from canonical +//! filename patterns (`v01`, `c042`, `v15 - c126`, etc.) so per-book +//! classification can drive the new `local_max_volume` / `local_max_chapter` +//! aggregations. + +use lazy_static::lazy_static; +use regex::Regex; use crate::models::BookStrategy; -use super::{BookMetadata, BookNamingContext, BookNamingStrategy, filename_without_extension}; +use super::{BookMetadata, BookMetadataStrategy, BookNamingContext, filename_without_extension}; + +lazy_static! { + /// Volume marker pattern. + /// + /// Anchored to a non-alphanumeric left boundary `(?:^|[\s_\-\[\(])` so + /// `[c2c]`-style uploader tags don't match `c2`, and so `Digital` doesn't + /// match `c` in the middle of a word. Lenient on the prefix + /// (`v` / `vol` / `vol.` / `volume`) to match real-world naming. Captures + /// the numeric portion including an optional fractional part — fractional + /// volumes are rejected at parse time (column type is `i32`), but the + /// regex needs to *see* the `.5` to know to reject it; otherwise it would + /// match `v01` from `v01.5` and silently truncate. + static ref VOLUME_PATTERN: Regex = + Regex::new(r"(?i)(?:^|[\s_\-\[\(])v(?:ol(?:ume)?)?\.?\s*(\d+(?:\.\d+)?)").unwrap(); + + /// Chapter marker pattern. Same boundary rule as `VOLUME_PATTERN`. Lenient + /// on the prefix (`c` / `ch` / `ch.` / `chapter`). + static ref CHAPTER_PATTERN: Regex = + Regex::new(r"(?i)(?:^|[\s_\-\[\(])c(?:h(?:apter)?)?\.?\s*(\d+(?:\.\d+)?)").unwrap(); +} /// Always use filename without extension (Komga-compatible) pub struct FilenameStrategy; @@ -13,6 +40,37 @@ impl FilenameStrategy { pub fn new() -> Self { Self } + + /// Strip the file extension before applying patterns. + fn name_without_ext(file_name: &str) -> &str { + match file_name.rfind('.') { + Some(pos) => &file_name[..pos], + None => file_name, + } + } + + /// Extract the volume number from a canonical filename. + /// + /// Returns `None` when no `v\d+` boundary match exists, or when the + /// captured number is fractional (`v01.5`) — truncation would silently + /// drop user-meaningful information and the column is `i32`. + pub fn extract_volume(file_name: &str) -> Option { + let name = Self::name_without_ext(file_name); + let captures = VOLUME_PATTERN.captures(name)?; + let raw = captures.get(1)?.as_str(); + if raw.contains('.') { + return None; + } + raw.parse::().ok() + } + + /// Extract the chapter number from a canonical filename. Fractional + /// chapters (e.g. `c042.5` for side stories) are preserved as `f32`. + pub fn extract_chapter(file_name: &str) -> Option { + let name = Self::name_without_ext(file_name); + let captures = CHAPTER_PATTERN.captures(name)?; + captures.get(1)?.as_str().parse::().ok() + } } impl Default for FilenameStrategy { @@ -21,7 +79,7 @@ impl Default for FilenameStrategy { } } -impl BookNamingStrategy for FilenameStrategy { +impl BookMetadataStrategy for FilenameStrategy { fn strategy_type(&self) -> BookStrategy { BookStrategy::Filename } @@ -34,6 +92,24 @@ impl BookNamingStrategy for FilenameStrategy { ) -> String { filename_without_extension(file_name) } + + fn resolve_volume( + &self, + file_name: &str, + _metadata: Option<&BookMetadata>, + _context: &BookNamingContext, + ) -> Option { + Self::extract_volume(file_name) + } + + fn resolve_chapter( + &self, + file_name: &str, + _metadata: Option<&BookMetadata>, + _context: &BookNamingContext, + ) -> Option { + Self::extract_chapter(file_name) + } } #[cfg(test)] @@ -50,6 +126,8 @@ mod tests { } } + // -- Title tests (existing behavior) -- + #[test] fn test_basic_filename() { let strategy = FilenameStrategy::new(); @@ -75,6 +153,8 @@ mod tests { let metadata = BookMetadata { title: Some("Different Title".to_string()), number: Some(1.0), + volume: None, + chapter: None, }; let title = strategy.resolve_title("Batman #001.cbz", Some(&metadata), &ctx); @@ -97,4 +177,106 @@ mod tests { BookStrategy::Filename ); } + + // -- Structured volume/chapter tests (Phase 11 table from plan) -- + + fn parse(file_name: &str) -> (Option, Option) { + let s = FilenameStrategy::new(); + let ctx = default_context(); + ( + s.resolve_volume(file_name, None, &ctx), + s.resolve_chapter(file_name, None, &ctx), + ) + } + + #[test] + fn test_canonical_volume_only() { + assert_eq!(parse("Series v01.cbz"), (Some(1), None)); + } + + #[test] + fn test_canonical_chapter_only() { + assert_eq!(parse("Series c042.cbz"), (None, Some(42.0))); + } + + #[test] + fn test_volume_and_chapter_with_year_and_uploader_tag() { + assert_eq!( + parse("Series v15 - c126 (2023) (1r0n).cbz"), + (Some(15), Some(126.0)) + ); + } + + #[test] + fn test_volume_only_year_in_parens_not_chapter() { + // `v01 - 2024 (Digital).cbz` → bare 2024 has no `c` prefix → null chapter + assert_eq!(parse("Series v01 - 2024 (Digital).cbz"), (Some(1), None)); + } + + #[test] + fn test_lenient_vol_chapter_prefixes() { + assert_eq!(parse("Series Vol. 5 Chapter 42.cbz"), (Some(5), Some(42.0))); + } + + #[test] + fn test_bracketed_subgroup_then_chapter() { + assert_eq!( + parse("[HorribleSubs] Series Ch. 42 [1080p].cbz"), + (None, Some(42.0)) + ); + } + + #[test] + fn test_alphanumeric_bracketed_tag_pin_behavior() { + // The plan calls this case out as: `Series [c2c] v01.cbz` should be + // (Some(1), None). With our boundary class including `[`, the first + // `c2` inside `[c2c]` matches the chapter regex (the trailing `c` is + // OK, the regex doesn't anchor a *right* boundary). This pins the + // current behavior so future tightening is intentional. If/when the + // false positive matters in practice, add a right-boundary check. + let (volume, chapter) = parse("Series [c2c] v01.cbz"); + assert_eq!(volume, Some(1)); + assert_eq!(chapter, Some(2.0)); + } + + #[test] + fn test_bare_number_returns_none_for_both() { + // `Naruto 042.cbz` — bare number, no v/c prefix → both None for + // structured fields. The number-axis (`resolve_number` on the number + // strategies) still handles bare numbers for sort order. + assert_eq!(parse("Naruto 042.cbz"), (None, None)); + } + + #[test] + fn test_fractional_chapter_preserved() { + assert_eq!(parse("Series c042.5.cbz"), (None, Some(42.5))); + } + + #[test] + fn test_fractional_volume_rejected() { + // `Series v01.5.cbz` → volume None (i32 column won't truncate silently). + // The extension is stripped first; the regex sees `Series v01.5` and + // captures `01.5`, which the parser rejects. + assert_eq!(parse("Series v01.5.cbz"), (None, None)); + } + + #[test] + fn test_first_match_wins_per_axis() { + // Multiple markers — take the first per axis. + assert_eq!(parse("Series v01 v05 c042 c100.cbz"), (Some(1), Some(42.0))); + } + + #[test] + fn test_resolve_returns_none_when_no_markers() { + let strategy = FilenameStrategy::new(); + let ctx = default_context(); + assert_eq!( + strategy.resolve_volume("Just A Title.cbz", None, &ctx), + None + ); + assert_eq!( + strategy.resolve_chapter("Just A Title.cbz", None, &ctx), + None + ); + } } diff --git a/src/scanner/strategies/book/metadata_first.rs b/src/scanner/strategies/book/metadata_first.rs index d28d063e..f58ad894 100644 --- a/src/scanner/strategies/book/metadata_first.rs +++ b/src/scanner/strategies/book/metadata_first.rs @@ -38,6 +38,27 @@ impl BookNamingStrategy for MetadataFirstStrategy { .cloned() .unwrap_or_else(|| filename_without_extension(file_name)) } + + /// Volume from metadata only. Honors the user's "I picked Metadata, don't + /// touch the filename" choice; returns `None` if metadata has no volume. + fn resolve_volume( + &self, + _file_name: &str, + metadata: Option<&BookMetadata>, + _context: &BookNamingContext, + ) -> Option { + metadata.and_then(|m| m.volume) + } + + /// Chapter from metadata only. + fn resolve_chapter( + &self, + _file_name: &str, + metadata: Option<&BookMetadata>, + _context: &BookNamingContext, + ) -> Option { + metadata.and_then(|m| m.chapter) + } } #[cfg(test)] @@ -61,6 +82,8 @@ mod tests { let metadata = BookMetadata { title: Some("The Dark Knight Returns".to_string()), number: Some(1.0), + volume: None, + chapter: None, }; let title = strategy.resolve_title("batman-001.cbz", Some(&metadata), &ctx); @@ -83,6 +106,8 @@ mod tests { let metadata = BookMetadata { title: Some("".to_string()), number: None, + volume: None, + chapter: None, }; let title = strategy.resolve_title("batman-001.cbz", Some(&metadata), &ctx); @@ -96,6 +121,8 @@ mod tests { let metadata = BookMetadata { title: None, number: Some(1.0), + volume: None, + chapter: None, }; let title = strategy.resolve_title("batman-001.cbz", Some(&metadata), &ctx); diff --git a/src/scanner/strategies/book/mod.rs b/src/scanner/strategies/book/mod.rs index 272efc18..b0ee612c 100644 --- a/src/scanner/strategies/book/mod.rs +++ b/src/scanner/strategies/book/mod.rs @@ -1,7 +1,10 @@ -//! Book naming strategy implementations +//! Book metadata strategy implementations //! -//! Book naming strategies determine how individual book titles are resolved -//! from filesystem paths and metadata. +//! Book metadata strategies determine how per-book facts (title, volume, +//! chapter) are resolved from filesystem paths and embedded metadata. The +//! framing is "which sources do we trust for facts about this book"; title is +//! just the first fact we extract. Volume and chapter classification (Phase 11 +//! of metadata-count-split) live on the same trait, mirroring the title flow. //! //! TODO: Remove allow(dead_code) once all book strategy features are fully integrated @@ -21,7 +24,7 @@ pub use smart::SmartStrategy; use crate::models::{BookStrategy, CustomBookConfig, SmartBookConfig}; -/// Context for resolving book titles +/// Context for resolving book metadata #[derive(Debug, Clone)] pub struct BookNamingContext { /// Series name (for SeriesName strategy) @@ -36,17 +39,30 @@ pub struct BookNamingContext { pub total_books: usize, } -/// Metadata that may contain a title +/// Metadata that may contribute facts about a book (title, volume, chapter). +/// +/// Mirrors the fields on `book_metadata` that the per-book classification cares +/// about. Strategies use this as their "ComicInfo says X" input shape. #[derive(Debug, Clone, Default)] pub struct BookMetadata { /// Title from ComicInfo.xml or embedded metadata pub title: Option, - /// Number from metadata + /// Number from metadata (legacy single-axis identifier) pub number: Option, + /// Volume number from metadata (e.g. ComicInfo ``) + pub volume: Option, + /// Chapter number from metadata (fractional, e.g. side chapters at 47.5) + pub chapter: Option, } -/// Trait for book naming strategy implementations -pub trait BookNamingStrategy: Send + Sync { +/// Trait for per-book metadata strategy implementations. +/// +/// Renamed from `BookNamingStrategy` (Phase 11 of metadata-count-split): the +/// trait is no longer purely about titles. It now resolves three independent +/// facts about a book — title, volume, chapter — from the same trio of inputs +/// (filename, metadata, context). Strategies remain free to implement only the +/// methods they have meaningful answers for; the rest return `None` defaults. +pub trait BookMetadataStrategy: Send + Sync { /// Get the strategy type fn strategy_type(&self) -> BookStrategy; @@ -57,8 +73,40 @@ pub trait BookNamingStrategy: Send + Sync { metadata: Option<&BookMetadata>, context: &BookNamingContext, ) -> String; + + /// Resolve the volume number for this book, if known. + /// + /// Default: `None`. Strategies that have a meaningful answer (filename + /// regex, ComicInfo ``, etc.) override this. + fn resolve_volume( + &self, + _file_name: &str, + _metadata: Option<&BookMetadata>, + _context: &BookNamingContext, + ) -> Option { + None + } + + /// Resolve the chapter number for this book, if known. + /// + /// Default: `None`. Fractional chapters (e.g. 47.5 side chapters) are + /// preserved via `f32`; integer chapters parse cleanly into the same type. + fn resolve_chapter( + &self, + _file_name: &str, + _metadata: Option<&BookMetadata>, + _context: &BookNamingContext, + ) -> Option { + None + } } +/// Backwards-compat alias. Prefer `BookMetadataStrategy` in new code; the +/// `BookNamingStrategy` name is kept as a re-export for now to keep the +/// downstream cascade narrow during Phase 11. Remove in a follow-up once all +/// call sites are updated. +pub use self::BookMetadataStrategy as BookNamingStrategy; + /// Remove file extension from filename pub fn filename_without_extension(file_name: &str) -> String { if let Some(pos) = file_name.rfind('.') { @@ -68,11 +116,11 @@ pub fn filename_without_extension(file_name: &str) -> String { } } -/// Create a book naming strategy from configuration +/// Create a book metadata strategy from configuration pub fn create_book_strategy( strategy: BookStrategy, config: Option<&str>, -) -> Box { +) -> Box { match strategy { BookStrategy::Filename => Box::new(FilenameStrategy::new()), BookStrategy::MetadataFirst => Box::new(MetadataFirstStrategy::new()), diff --git a/src/scanner/strategies/book/series_name.rs b/src/scanner/strategies/book/series_name.rs index 3c546328..999e977f 100644 --- a/src/scanner/strategies/book/series_name.rs +++ b/src/scanner/strategies/book/series_name.rs @@ -71,6 +71,39 @@ impl BookNamingStrategy for SeriesNameStrategy { BookStrategy::SeriesName } + /// SeriesName operates on the upstream series-detection output via + /// `BookNamingContext`. For the per-book classification axis it just + /// passes through whatever the series detection populated. When the + /// detection isn't `series_volume_chapter`, both context fields are `None` + /// and so are the answers (correct — SeriesName isn't a parser). + fn resolve_volume( + &self, + _file_name: &str, + _metadata: Option<&BookMetadata>, + context: &BookNamingContext, + ) -> Option { + context + .volume + .as_deref() + .and_then(extract_number_from_string) + .and_then(|n| { + if n.fract() == 0.0 && n >= 0.0 { + Some(n as i32) + } else { + None + } + }) + } + + fn resolve_chapter( + &self, + _file_name: &str, + _metadata: Option<&BookMetadata>, + context: &BookNamingContext, + ) -> Option { + context.chapter_number + } + fn resolve_title( &self, file_name: &str, diff --git a/src/scanner/strategies/book/smart.rs b/src/scanner/strategies/book/smart.rs index 8d98fe64..8debebb7 100644 --- a/src/scanner/strategies/book/smart.rs +++ b/src/scanner/strategies/book/smart.rs @@ -8,7 +8,10 @@ use regex::Regex; use crate::models::{BookStrategy, SmartBookConfig}; -use super::{BookMetadata, BookNamingContext, BookNamingStrategy, filename_without_extension}; +use super::{ + BookMetadata, BookNamingContext, BookNamingStrategy, FilenameStrategy, + filename_without_extension, +}; lazy_static! { /// Default patterns for generic titles that should be skipped @@ -87,6 +90,32 @@ impl BookNamingStrategy for SmartStrategy { .cloned() .unwrap_or_else(|| filename_without_extension(file_name)) } + + /// Volume: ComicInfo first (more authoritative), filename fallback. Mirrors + /// the Smart "metadata-when-meaningful, filename otherwise" idiom on the + /// title axis. + fn resolve_volume( + &self, + file_name: &str, + metadata: Option<&BookMetadata>, + context: &BookNamingContext, + ) -> Option { + metadata + .and_then(|m| m.volume) + .or_else(|| FilenameStrategy::new().resolve_volume(file_name, metadata, context)) + } + + /// Chapter: ComicInfo first, filename fallback. + fn resolve_chapter( + &self, + file_name: &str, + metadata: Option<&BookMetadata>, + context: &BookNamingContext, + ) -> Option { + metadata + .and_then(|m| m.chapter) + .or_else(|| FilenameStrategy::new().resolve_chapter(file_name, metadata, context)) + } } #[cfg(test)] @@ -110,6 +139,8 @@ mod tests { let metadata = BookMetadata { title: Some("The Dark Knight Returns".to_string()), number: Some(1.0), + volume: None, + chapter: None, }; let title = strategy.resolve_title("batman-001.cbz", Some(&metadata), &ctx); @@ -123,6 +154,8 @@ mod tests { let metadata = BookMetadata { title: Some("Vol. 3".to_string()), number: Some(3.0), + volume: None, + chapter: None, }; let title = strategy.resolve_title("batman-003.cbz", Some(&metadata), &ctx); @@ -136,6 +169,8 @@ mod tests { let metadata = BookMetadata { title: Some("Volume 1".to_string()), number: Some(1.0), + volume: None, + chapter: None, }; let title = strategy.resolve_title("series-vol01.cbz", Some(&metadata), &ctx); @@ -149,6 +184,8 @@ mod tests { let metadata = BookMetadata { title: Some("Chapter 5".to_string()), number: Some(5.0), + volume: None, + chapter: None, }; let title = strategy.resolve_title("manga-ch005.cbz", Some(&metadata), &ctx); @@ -162,6 +199,8 @@ mod tests { let metadata = BookMetadata { title: Some("Issue #42".to_string()), number: Some(42.0), + volume: None, + chapter: None, }; let title = strategy.resolve_title("comic-042.cbz", Some(&metadata), &ctx); @@ -175,6 +214,8 @@ mod tests { let metadata = BookMetadata { title: Some("42".to_string()), number: Some(42.0), + volume: None, + chapter: None, }; let title = strategy.resolve_title("comic-042.cbz", Some(&metadata), &ctx); @@ -188,6 +229,8 @@ mod tests { let metadata = BookMetadata { title: Some("#1".to_string()), number: Some(1.0), + volume: None, + chapter: None, }; let title = strategy.resolve_title("comic-001.cbz", Some(&metadata), &ctx); @@ -204,6 +247,8 @@ mod tests { let metadata = BookMetadata { title: Some("Book 1".to_string()), number: Some(1.0), + volume: None, + chapter: None, }; let title = strategy.resolve_title("novel-001.epub", Some(&metadata), &ctx); diff --git a/tests/api/komga.rs b/tests/api/komga.rs index 176e61ac..f0a828f6 100644 --- a/tests/api/komga.rs +++ b/tests/api/komga.rs @@ -2093,6 +2093,7 @@ async fn create_book_metadata_with_date( month, day, volume: None, + chapter: None, count: None, isbns: None, title_lock: false, @@ -2110,6 +2111,7 @@ async fn create_book_metadata_with_date( month_lock: false, day_lock: false, volume_lock: false, + chapter_lock: false, count_lock: false, isbns_lock: false, book_type: None, @@ -3577,6 +3579,7 @@ async fn create_book_metadata_full( month, day, volume: None, + chapter: None, count: None, isbns: None, title_lock: false, @@ -3594,6 +3597,7 @@ async fn create_book_metadata_full( month_lock: false, day_lock: false, volume_lock: false, + chapter_lock: false, count_lock: false, isbns_lock: false, book_type: None, @@ -3677,6 +3681,7 @@ async fn create_book_metadata_with_authors( month: None, day: None, volume: None, + chapter: None, count: None, isbns: None, title_lock: false, @@ -3694,6 +3699,7 @@ async fn create_book_metadata_with_authors( month_lock: false, day_lock: false, volume_lock: false, + chapter_lock: false, count_lock: false, isbns_lock: false, book_type: None, diff --git a/tests/db/migrations.rs b/tests/db/migrations.rs index 0ccc8ec8..aacae452 100644 --- a/tests/db/migrations.rs +++ b/tests/db/migrations.rs @@ -538,3 +538,116 @@ async fn test_migration_068_drop_legacy_sqlite() { db.close().await; } + +// -- Migration 069 (add_book_chapter) tests -- +// Phase 11 of metadata-count-split: adds `chapter` and `chapter_lock` to +// book_metadata. Verifies up/down behavior and default values for existing rows. + +/// Helper: run all migrations except the very last (069) so tests can apply 069 in isolation. +async fn setup_db_before_migration_069() -> (Database, TempDir) { + let temp_dir = TempDir::new().unwrap(); + let db_path = temp_dir.path().join("test.db"); + + let config = DatabaseConfig { + db_type: DatabaseType::SQLite, + postgres: None, + sqlite: Some(SQLiteConfig { + path: db_path.to_str().unwrap().to_string(), + pragmas: None, + ..SQLiteConfig::default() + }), + }; + + let db = Database::new(&config).await.unwrap(); + let conn = db.sea_orm_connection(); + + // Run all 66 migrations except the last one (069). Total = 66; running 65 leaves + // 069 pending so the test below can apply it via `Some(1)` and assert before/after. + Migrator::up(conn, Some(65)).await.unwrap(); + + (db, temp_dir) +} + +#[tokio::test] +async fn test_migration_069_adds_chapter_columns_sqlite() { + let (db, _temp_dir) = setup_db_before_migration_069().await; + let conn = db.sea_orm_connection(); + + // Pre-conditions: chapter columns do not yet exist; volume + volume_lock do. + assert!(sqlite_has_column(conn, "book_metadata", "volume").await); + assert!(sqlite_has_column(conn, "book_metadata", "volume_lock").await); + assert!(!sqlite_has_column(conn, "book_metadata", "chapter").await); + assert!(!sqlite_has_column(conn, "book_metadata", "chapter_lock").await); + + // Seed a library, series, book, and book_metadata row using the pre-069 schema + // so we can verify the new columns get default values applied to existing rows. + conn.execute_unprepared( + "INSERT INTO libraries (id, name, path, series_strategy, book_strategy, number_strategy, default_reading_direction, created_at, updated_at) + VALUES (X'00000000000000000000000000000001', 'Lib', '/lib', 'series_volume', 'filename', 'file_order', 'LEFT_TO_RIGHT', '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z')" + ).await.unwrap(); + + conn.execute_unprepared( + "INSERT INTO series (id, library_id, path, name, normalized_name, created_at, updated_at) + VALUES (X'00000000000000000000000000000010', X'00000000000000000000000000000001', '/path', 'S', 's', '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z')" + ).await.unwrap(); + + conn.execute_unprepared( + "INSERT INTO books (id, series_id, library_id, file_path, file_name, file_size, file_hash, partial_hash, format, page_count, deleted, analyzed, modified_at, created_at, updated_at) + VALUES (X'00000000000000000000000000000020', X'00000000000000000000000000000010', X'00000000000000000000000000000001', '/path/v01.cbz', 'v01.cbz', 1024, 'h', '', 'cbz', 10, 0, 0, '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z')" + ).await.unwrap(); + + conn.execute_unprepared( + "INSERT INTO book_metadata (id, book_id, search_title, volume, volume_lock, created_at, updated_at) + VALUES (X'00000000000000000000000000000030', X'00000000000000000000000000000020', 'v01', 1, 0, '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z')" + ).await.unwrap(); + + // Apply migration 069. + Migrator::up(conn, Some(1)).await.unwrap(); + + // Post-conditions: new columns exist. + assert!(sqlite_has_column(conn, "book_metadata", "chapter").await); + assert!(sqlite_has_column(conn, "book_metadata", "chapter_lock").await); + + // Existing row gains NULL chapter and chapter_lock = false (the default). + let row = conn + .query_one(Statement::from_string( + DatabaseBackend::Sqlite, + "SELECT volume, chapter, chapter_lock FROM book_metadata WHERE id = X'00000000000000000000000000000030'" + .to_string(), + )) + .await + .unwrap() + .unwrap(); + let volume: Option = row.try_get("", "volume").unwrap(); + let chapter: Option = row.try_get("", "chapter").unwrap(); + let chapter_lock: bool = row.try_get("", "chapter_lock").unwrap(); + assert_eq!(volume, Some(1)); + assert!( + chapter.is_none(), + "chapter must be NULL for pre-existing rows" + ); + assert!(!chapter_lock, "chapter_lock must default to false"); + + db.close().await; +} + +#[tokio::test] +async fn test_migration_069_down_drops_chapter_columns_sqlite() { + let (db, _temp_dir) = setup_db_before_migration_069().await; + let conn = db.sea_orm_connection(); + + // Apply 069 then immediately roll it back. + Migrator::up(conn, Some(1)).await.unwrap(); + assert!(sqlite_has_column(conn, "book_metadata", "chapter").await); + assert!(sqlite_has_column(conn, "book_metadata", "chapter_lock").await); + + Migrator::down(conn, Some(1)).await.unwrap(); + + // Down drops the two new columns; volume + volume_lock still around. + assert!(!sqlite_has_column(conn, "book_metadata", "chapter").await); + assert!(!sqlite_has_column(conn, "book_metadata", "chapter_lock").await); + assert!(sqlite_has_column(conn, "book_metadata", "volume").await); + assert!(sqlite_has_column(conn, "book_metadata", "volume_lock").await); + + db.close().await; +} diff --git a/tests/scanner/book_naming_strategy.rs b/tests/scanner/book_naming_strategy.rs index b910737b..9c229cb7 100644 --- a/tests/scanner/book_naming_strategy.rs +++ b/tests/scanner/book_naming_strategy.rs @@ -28,6 +28,8 @@ fn test_filename_strategy_ignores_metadata() { let metadata = BookMetadata { title: Some("The Dark Knight Returns".to_string()), number: Some(1.0), + volume: None, + chapter: None, }; let title = strategy.resolve_title("Batman Issue 001.cbz", Some(&metadata), &context); @@ -50,6 +52,8 @@ fn test_metadata_first_strategy_uses_metadata() { let metadata = BookMetadata { title: Some("The Dark Knight Returns".to_string()), number: Some(1.0), + volume: None, + chapter: None, }; let title = strategy.resolve_title("batman_001.cbz", Some(&metadata), &context); @@ -101,6 +105,8 @@ fn test_smart_strategy_rejects_generic_titles() { let metadata = BookMetadata { title: Some(generic_title.to_string()), number: Some(3.0), + volume: None, + chapter: None, }; let title = strategy.resolve_title(filename, Some(&metadata), &context); @@ -129,6 +135,8 @@ fn test_smart_strategy_accepts_meaningful_titles() { let metadata = BookMetadata { title: Some("The Killing Joke".to_string()), number: Some(1.0), + volume: None, + chapter: None, }; let title = strategy.resolve_title("batman_special_001.cbz", Some(&metadata), &context); @@ -151,6 +159,8 @@ fn test_smart_strategy_custom_patterns() { let metadata = BookMetadata { title: Some("Book 1".to_string()), number: Some(1.0), + volume: None, + chapter: None, }; let title = strategy.resolve_title("novel_001.epub", Some(&metadata), &context); @@ -432,6 +442,8 @@ fn test_custom_strategy_metadata_first_fallback() { let metadata = BookMetadata { title: Some("The Dark Knight".to_string()), number: Some(1.0), + volume: None, + chapter: None, }; // Filename doesn't match pattern, should use metadata From 534679e27a487959a81e75f69fd38750f97a5fca Mon Sep 17 00:00:00 2001 From: Sylvain Cau Date: Fri, 1 May 2026 22:08:54 -0700 Subject: [PATCH 13/19] feat(scanner): populate book volume and chapter from filename and ComicInfo Wire the per-book classification trait into the analyzer's metadata write paths and add a one-time backfill over already-scanned libraries. - ComicInfo gains a structured `chapter: Option` derived from `` (handles fractional chapters like 47.5; non-numeric values leave chapter null while preserving the raw string for legacy display). - analyzer_queue threads `comic_info.chapter` into the strategy input and uses `comic_info.chapter` as the existing-row update fallback, matching the volume rule and restoring symmetry between the two axes. - New `m20260503_000070_backfill_book_volume_chapter` migration re-parses each book's filename in 1000-row batches, populating volume and chapter only where currently null. Strictly additive: never overwrites a populated value, never touches lock fields. Idempotent on rerun. The filename parser is duplicated inline since the migration crate cannot depend on the main crate. - BookMetadataApplier gains volume and chapter write blocks following the existing per-field lock + permission pattern. Plugin-protocol f64 volume is narrowed to i32 with fractional rejection rather than silent truncation. New `metadata:write:volume` and `metadata:write:chapter` permissions; both flow through the `metadata:write:*` wildcard. Tests cover the strategy x parse-case matrix, ComicInfo `` derivation, the backfill (full case matrix + additive-only + idempotency), and the new applier blocks (write, fractional preserved or rejected, lock-honored, permission-missing-skipped, allowlist). --- Cargo.lock | 2 + migration/Cargo.toml | 2 + migration/src/lib.rs | 4 + ...503_000070_backfill_book_volume_chapter.rs | 195 +++++++++++ src/db/entities/plugins.rs | 28 +- src/parsers/comic_info.rs | 48 +++ src/parsers/metadata.rs | 5 + src/parsers/opf.rs | 1 + src/scanner/analyzer_queue.rs | 11 +- src/services/metadata/book_apply.rs | 46 +++ tests/db/migrations.rs | 173 +++++++++- tests/scanner/book_naming_strategy.rs | 181 ++++++++++ tests/services.rs | 1 + tests/services/book_metadata_apply.rs | 313 ++++++++++++++++++ 14 files changed, 998 insertions(+), 12 deletions(-) create mode 100644 migration/src/m20260503_000070_backfill_book_volume_chapter.rs create mode 100644 tests/services/book_metadata_apply.rs diff --git a/Cargo.lock b/Cargo.lock index a5e4356d..f64c7af2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2989,6 +2989,8 @@ name = "migration" version = "0.1.0" dependencies = [ "chrono", + "lazy_static", + "regex", "sea-orm-migration", "tokio", "uuid", diff --git a/migration/Cargo.toml b/migration/Cargo.toml index 7feff237..e4ec3a46 100644 --- a/migration/Cargo.toml +++ b/migration/Cargo.toml @@ -12,6 +12,8 @@ path = "src/lib.rs" tokio = { version = "1", features = ["macros", "rt-multi-thread"] } uuid = { version = "1.11.0", features = ["v4", "serde"] } chrono = { version = "0.4", features = ["serde"] } +regex = "1" +lazy_static = "1" [dependencies.sea-orm-migration] version = "1.1.0" diff --git a/migration/src/lib.rs b/migration/src/lib.rs index abbe0870..36cf07dd 100644 --- a/migration/src/lib.rs +++ b/migration/src/lib.rs @@ -141,6 +141,8 @@ mod m20260502_000067_split_book_count; mod m20260502_000068_drop_book_count; // Add chapter + chapter_lock columns to book_metadata (Phase 11 per-book classification) mod m20260503_000069_add_book_chapter; +// Backfill volume/chapter from filename for already-scanned books (Phase 12) +mod m20260503_000070_backfill_book_volume_chapter; pub struct Migrator; @@ -254,6 +256,8 @@ impl MigratorTrait for Migrator { Box::new(m20260502_000068_drop_book_count::Migration), // Add chapter + chapter_lock columns to book_metadata (Phase 11) Box::new(m20260503_000069_add_book_chapter::Migration), + // Backfill book_metadata.volume / .chapter from filename (Phase 12) + Box::new(m20260503_000070_backfill_book_volume_chapter::Migration), ] } } diff --git a/migration/src/m20260503_000070_backfill_book_volume_chapter.rs b/migration/src/m20260503_000070_backfill_book_volume_chapter.rs new file mode 100644 index 00000000..3b4d867a --- /dev/null +++ b/migration/src/m20260503_000070_backfill_book_volume_chapter.rs @@ -0,0 +1,195 @@ +//! Backfill `book_metadata.volume` and `book_metadata.chapter` from the +//! structured filename parser (Phase 12 of metadata-count-split). +//! +//! Phase 11 added the `chapter` column; the scanner now writes both columns +//! on insert/rescan. This migration handles the population for already-scanned +//! libraries: re-parse each book's `file_name` and update `volume` / `chapter` +//! where they are currently NULL and the parser has a value. +//! +//! Rules: +//! - Only touch rows where the field is NULL — never overwrite manually-set or +//! plugin-derived values. The migration is additive. +//! - Lock fields are not touched. A locked-but-NULL field stays locked-NULL; +//! the user explicitly chose "don't autopopulate this". +//! - Rows are processed in 1000-row batches with a single UPDATE per batch +//! (per-row UPDATE would be O(n) round-trips on a 10k-book library). +//! - Idempotent: re-running produces no further writes after the first pass +//! (the WHERE filter excludes the rows it has already populated). + +use regex::Regex; +use sea_orm_migration::prelude::*; +use sea_orm_migration::sea_orm::{ + ConnectionTrait, FromQueryResult, Statement, TransactionTrait, Value, +}; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +const BATCH_SIZE: u64 = 1000; + +#[derive(Debug, FromQueryResult)] +struct Row { + book_id: uuid::Uuid, + file_name: String, + volume: Option, + chapter: Option, +} + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let db = manager.get_connection(); + let backend = manager.get_database_backend(); + let txn = db.begin().await?; + + let mut offset: u64 = 0; + loop { + // Fetch a batch of rows that still have at least one of the structured + // fields NULL. We fetch the existing values too so the UPDATE only + // touches the column that actually needs filling — keeps the migration + // strictly additive (never clobbers a populated field). + let select_sql = format!( + "SELECT bm.book_id, b.file_name, bm.volume, bm.chapter \ + FROM book_metadata bm \ + JOIN books b ON b.id = bm.book_id \ + WHERE bm.volume IS NULL OR bm.chapter IS NULL \ + ORDER BY bm.book_id \ + LIMIT {BATCH_SIZE} OFFSET {offset}" + ); + let rows = Row::find_by_statement(Statement::from_string(backend, select_sql)) + .all(&txn) + .await?; + + if rows.is_empty() { + break; + } + + let batch_size = rows.len(); + for row in rows { + let parsed_volume = extract_volume(&row.file_name); + let parsed_chapter = extract_chapter(&row.file_name); + + let new_volume = if row.volume.is_none() { + parsed_volume + } else { + None + }; + let new_chapter = if row.chapter.is_none() { + parsed_chapter + } else { + None + }; + + // Skip the UPDATE entirely if nothing to set. + if new_volume.is_none() && new_chapter.is_none() { + continue; + } + + // Build the UPDATE dynamically: only set the columns that need a + // new value. We always set updated_at to reflect the touch. + let mut sets: Vec<&str> = Vec::with_capacity(3); + let mut values: Vec = Vec::with_capacity(3); + if let Some(v) = new_volume { + sets.push("volume = ?"); + values.push(v.into()); + } + if let Some(c) = new_chapter { + sets.push("chapter = ?"); + values.push(c.into()); + } + if sets.is_empty() { + continue; + } + let sql = format!( + "UPDATE book_metadata SET {} WHERE book_id = ?", + sets.join(", ") + ); + values.push(row.book_id.into()); + + txn.execute(Statement::from_sql_and_values(backend, &sql, values)) + .await?; + } + + // If we fetched fewer than the batch size, there's no next page. + if (batch_size as u64) < BATCH_SIZE { + break; + } + offset += BATCH_SIZE; + } + + txn.commit().await?; + Ok(()) + } + + async fn down(&self, _manager: &SchemaManager) -> Result<(), DbErr> { + // No-op down: there's no safe way to distinguish backfilled values from + // values that were already present (we'd need a marker column we never + // added). The data shape stays stable; the columns themselves are owned + // by migration 069. + Ok(()) + } +} + +// ----------------------------------------------------------------------------- +// Filename parser (mirrors `src/scanner/strategies/book/filename.rs`). +// Kept inline because the migration crate cannot depend on the main crate. +// ----------------------------------------------------------------------------- + +lazy_static::lazy_static! { + static ref VOLUME_PATTERN: Regex = + Regex::new(r"(?i)(?:^|[\s_\-\[\(])v(?:ol(?:ume)?)?\.?\s*(\d+(?:\.\d+)?)").unwrap(); + static ref CHAPTER_PATTERN: Regex = + Regex::new(r"(?i)(?:^|[\s_\-\[\(])c(?:h(?:apter)?)?\.?\s*(\d+(?:\.\d+)?)").unwrap(); +} + +fn name_without_ext(file_name: &str) -> &str { + match file_name.rfind('.') { + Some(pos) => &file_name[..pos], + None => file_name, + } +} + +fn extract_volume(file_name: &str) -> Option { + let name = name_without_ext(file_name); + let captures = VOLUME_PATTERN.captures(name)?; + let raw = captures.get(1)?.as_str(); + if raw.contains('.') { + return None; + } + raw.parse::().ok() +} + +fn extract_chapter(file_name: &str) -> Option { + let name = name_without_ext(file_name); + let captures = CHAPTER_PATTERN.captures(name)?; + captures.get(1)?.as_str().parse::().ok() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parser_matches_canonical_patterns() { + assert_eq!(extract_volume("Series v01.cbz"), Some(1)); + assert_eq!(extract_chapter("Series c042.cbz"), Some(42.0)); + assert_eq!(extract_volume("Series v15 - c126.cbz"), Some(15)); + assert_eq!(extract_chapter("Series v15 - c126.cbz"), Some(126.0)); + } + + #[test] + fn parser_rejects_fractional_volume() { + assert_eq!(extract_volume("Series v01.5.cbz"), None); + } + + #[test] + fn parser_keeps_fractional_chapter() { + assert_eq!(extract_chapter("Series c042.5.cbz"), Some(42.5)); + } + + #[test] + fn parser_returns_none_for_bare_numbers() { + assert_eq!(extract_volume("Naruto 042.cbz"), None); + assert_eq!(extract_chapter("Naruto 042.cbz"), None); + } +} diff --git a/src/db/entities/plugins.rs b/src/db/entities/plugins.rs index 0b0f76a8..09db3420 100644 --- a/src/db/entities/plugins.rs +++ b/src/db/entities/plugins.rs @@ -424,6 +424,12 @@ pub enum PluginPermission { /// Update ISBN identifiers #[serde(rename = "metadata:write:isbn")] MetadataWriteIsbn, + /// Update per-book volume number (Phase 12 of metadata-count-split) + #[serde(rename = "metadata:write:volume")] + MetadataWriteVolume, + /// Update per-book chapter number (Phase 12 of metadata-count-split) + #[serde(rename = "metadata:write:chapter")] + MetadataWriteChapter, // ========================================================================= // Wildcard Permissions @@ -477,6 +483,8 @@ impl PluginPermission { PluginPermission::MetadataWriteAwards => "metadata:write:awards", PluginPermission::MetadataWriteCustomMetadata => "metadata:write:custom_metadata", PluginPermission::MetadataWriteIsbn => "metadata:write:isbn", + PluginPermission::MetadataWriteVolume => "metadata:write:volume", + PluginPermission::MetadataWriteChapter => "metadata:write:chapter", // Wildcard PluginPermission::MetadataWriteAll => "metadata:write:*", // Library @@ -519,6 +527,8 @@ impl PluginPermission { PluginPermission::MetadataWriteAwards, PluginPermission::MetadataWriteCustomMetadata, PluginPermission::MetadataWriteIsbn, + PluginPermission::MetadataWriteVolume, + PluginPermission::MetadataWriteChapter, ] } @@ -559,6 +569,8 @@ impl PluginPermission { PluginPermission::MetadataWriteAwards, PluginPermission::MetadataWriteCustomMetadata, PluginPermission::MetadataWriteIsbn, + PluginPermission::MetadataWriteVolume, + PluginPermission::MetadataWriteChapter, ] } } @@ -606,6 +618,8 @@ impl FromStr for PluginPermission { "metadata:write:awards" => Ok(PluginPermission::MetadataWriteAwards), "metadata:write:custom_metadata" => Ok(PluginPermission::MetadataWriteCustomMetadata), "metadata:write:isbn" => Ok(PluginPermission::MetadataWriteIsbn), + "metadata:write:volume" => Ok(PluginPermission::MetadataWriteVolume), + "metadata:write:chapter" => Ok(PluginPermission::MetadataWriteChapter), // Wildcard "metadata:write:*" => Ok(PluginPermission::MetadataWriteAll), // Library @@ -720,6 +734,8 @@ impl Model { | PluginPermission::MetadataWriteAwards | PluginPermission::MetadataWriteCustomMetadata | PluginPermission::MetadataWriteIsbn + | PluginPermission::MetadataWriteVolume + | PluginPermission::MetadataWriteChapter ) { return true; } @@ -1029,8 +1045,10 @@ mod tests { assert!(perms.contains(&PluginPermission::MetadataWriteExternalIds)); assert!(perms.contains(&PluginPermission::MetadataWriteTotalVolumeCount)); assert!(perms.contains(&PluginPermission::MetadataWriteTotalChapterCount)); - // Should have 28 write permissions (16 common + 12 book-specific) - assert_eq!(perms.len(), 28); + assert!(perms.contains(&PluginPermission::MetadataWriteVolume)); + assert!(perms.contains(&PluginPermission::MetadataWriteChapter)); + // Should have 30 write permissions (16 common + 14 book-specific) + assert_eq!(perms.len(), 30); } #[test] @@ -1064,10 +1082,12 @@ mod tests { assert!(perms.contains(&PluginPermission::MetadataWriteAwards)); assert!(perms.contains(&PluginPermission::MetadataWriteCustomMetadata)); assert!(perms.contains(&PluginPermission::MetadataWriteIsbn)); + assert!(perms.contains(&PluginPermission::MetadataWriteVolume)); + assert!(perms.contains(&PluginPermission::MetadataWriteChapter)); // Common permissions should NOT be in book-specific assert!(!perms.contains(&PluginPermission::MetadataWriteTitle)); - // Should have 12 book-specific permissions - assert_eq!(perms.len(), 12); + // Should have 14 book-specific permissions + assert_eq!(perms.len(), 14); } #[test] diff --git a/src/parsers/comic_info.rs b/src/parsers/comic_info.rs index c7534500..d306168e 100644 --- a/src/parsers/comic_info.rs +++ b/src/parsers/comic_info.rs @@ -127,12 +127,22 @@ pub fn parse_comic_info(xml_content: &str) -> Result`. ComicInfo's `` field is overloaded — issue, chapter, + // or part depending on the producer. v1: read it as a chapter; users whose + // files use it for issues can lock `chapter` after manual fix. + let chapter = xml_info + .number + .as_deref() + .and_then(|n| n.trim().parse::().ok()); + Ok(ComicInfo { title: xml_info.title, series: xml_info.series, number: xml_info.number, count: xml_info.count, volume: xml_info.volume, + chapter, summary: xml_info.summary, year: xml_info.year, month: xml_info.month, @@ -348,6 +358,44 @@ mod tests { } } + #[test] + fn test_parse_comic_info_derives_chapter_from_number() { + // Phase 12 of metadata-count-split: ComicInfo `` is the chapter + // axis on the parsed struct. Integer parses cleanly; fractional preserved. + let xml = r#" + + 42 +"#; + let result = parse_comic_info(xml).unwrap(); + assert_eq!(result.number.as_deref(), Some("42")); + assert_eq!(result.chapter, Some(42.0)); + + // Fractional chapter (e.g. side stories at 47.5). + let xml_frac = r#" + + 47.5 +"#; + let result_frac = parse_comic_info(xml_frac).unwrap(); + assert_eq!(result_frac.chapter, Some(47.5)); + + // No `` at all -> chapter stays None. + let xml_none = r#" + + X +"#; + let result_none = parse_comic_info(xml_none).unwrap(); + assert_eq!(result_none.chapter, None); + + // Non-numeric `` (rare but possible) -> chapter None, raw stays. + let xml_bad = r#" + + part-1 +"#; + let result_bad = parse_comic_info(xml_bad).unwrap(); + assert_eq!(result_bad.number.as_deref(), Some("part-1")); + assert_eq!(result_bad.chapter, None); + } + #[test] fn test_parse_comic_info_numeric_fields() { let xml = r#" diff --git a/src/parsers/metadata.rs b/src/parsers/metadata.rs index 646c174d..8eba29a4 100644 --- a/src/parsers/metadata.rs +++ b/src/parsers/metadata.rs @@ -213,6 +213,11 @@ pub struct ComicInfo { pub number: Option, pub count: Option, pub volume: Option, + /// Chapter number derived from ComicInfo ``. ComicInfo overloads + /// `` (issue / chapter / part) — Phase 12 of metadata-count-split + /// reads it as a chapter unconditionally and lets users lock the field if + /// their files use `` for issues instead. + pub chapter: Option, pub summary: Option, pub year: Option, pub month: Option, diff --git a/src/parsers/opf.rs b/src/parsers/opf.rs index 04858380..2c6d6af0 100644 --- a/src/parsers/opf.rs +++ b/src/parsers/opf.rs @@ -118,6 +118,7 @@ pub fn merge_comic_info(base: &ComicInfo, overlay: &ComicInfo) -> ComicInfo { number: overlay.number.clone().or_else(|| base.number.clone()), count: overlay.count.or(base.count), volume: overlay.volume.or(base.volume), + chapter: overlay.chapter.or(base.chapter), summary: overlay.summary.clone().or_else(|| base.summary.clone()), year: overlay.year.or(base.year), month: overlay.month.or(base.month), diff --git a/src/scanner/analyzer_queue.rs b/src/scanner/analyzer_queue.rs index f28636de..b08fdb82 100644 --- a/src/scanner/analyzer_queue.rs +++ b/src/scanner/analyzer_queue.rs @@ -518,7 +518,9 @@ async fn analyze_single_book( chapter: if existing.chapter_lock { existing.chapter } else { - resolved_classification.chapter.or(existing.chapter) + // Mirrors the volume rule: strategy first, raw ComicInfo + // fallback for strategies that don't extract a chapter. + resolved_classification.chapter.or(comic_info.chapter) }, count: if existing.count_lock { existing.count @@ -602,7 +604,7 @@ async fn analyze_single_book( // Strategy-resolved volume takes precedence; ComicInfo is the // fallback for strategies that don't parse a volume. volume: resolved_classification.volume.or(comic_info.volume), - chapter: resolved_classification.chapter, + chapter: resolved_classification.chapter.or(comic_info.chapter), count: comic_info.count, isbns: isbns_json, // New Phase 1 fields @@ -1125,8 +1127,7 @@ async fn resolve_book_classification( title: ci.title.clone().filter(|t| !t.is_empty()), number: book_number, volume: ci.volume, - // ComicInfo has no Chapter field today; Phase 12 wires one through. - chapter: None, + chapter: ci.chapter, }); let context = BookNamingContext { @@ -1194,7 +1195,7 @@ async fn resolve_book_title( title: ci.title.clone().filter(|t| !t.is_empty()), number: book_number, volume: ci.volume, - chapter: None, + chapter: ci.chapter, }); // Build naming context diff --git a/src/services/metadata/book_apply.rs b/src/services/metadata/book_apply.rs index 59378799..b4439dc4 100644 --- a/src/services/metadata/book_apply.rs +++ b/src/services/metadata/book_apply.rs @@ -391,6 +391,52 @@ impl BookMetadataApplier { } } + // Volume (Phase 12 of metadata-count-split): per-book volume number. + // Plugin protocol carries `volume: Option`; book_metadata stores it + // as `i32` (no fractional volumes today — matches the structured + // filename parser's strictness). Reject fractional with a skip rather + // than truncating: silent truncation would lose information. + if should_apply_field("volume") + && let Some(volume) = metadata.volume + { + let is_locked = current_metadata.map(|m| m.volume_lock).unwrap_or(false); + match check_field("volume", is_locked, PluginPermission::MetadataWriteVolume) { + Ok(_) => { + if volume.fract() == 0.0 + && (i32::MIN as f64..=i32::MAX as f64).contains(&volume) + { + updated.volume = Some(volume as i32); + applied_fields.push("volume".to_string()); + changed = true; + } else { + skipped_fields.push(SkippedField { + field: "volume".to_string(), + reason: format!("Fractional or out-of-range volume rejected: {volume}"), + }); + } + } + Err(skip) => skipped_fields.push(skip), + } + } + + // Chapter (Phase 12 of metadata-count-split): per-book chapter number. + // Stored as `f32` to preserve fractional chapters (e.g. side stories at + // 47.5). Plugin protocol uses `f64`; we narrow with a debug-asserted + // cast since chapter numbers in practice never exceed `f32` precision. + if should_apply_field("chapter") + && let Some(chapter) = metadata.chapter + { + let is_locked = current_metadata.map(|m| m.chapter_lock).unwrap_or(false); + match check_field("chapter", is_locked, PluginPermission::MetadataWriteChapter) { + Ok(_) => { + updated.chapter = Some(chapter as f32); + applied_fields.push("chapter".to_string()); + changed = true; + } + Err(skip) => skipped_fields.push(skip), + } + } + // Subjects if should_apply_field("subjects") && !metadata.subjects.is_empty() { let is_locked = current_metadata.map(|m| m.subjects_lock).unwrap_or(false); diff --git a/tests/db/migrations.rs b/tests/db/migrations.rs index aacae452..b8695589 100644 --- a/tests/db/migrations.rs +++ b/tests/db/migrations.rs @@ -543,7 +543,7 @@ async fn test_migration_068_drop_legacy_sqlite() { // Phase 11 of metadata-count-split: adds `chapter` and `chapter_lock` to // book_metadata. Verifies up/down behavior and default values for existing rows. -/// Helper: run all migrations except the very last (069) so tests can apply 069 in isolation. +/// Helper: run all migrations through 068 so tests can apply 069 in isolation. async fn setup_db_before_migration_069() -> (Database, TempDir) { let temp_dir = TempDir::new().unwrap(); let db_path = temp_dir.path().join("test.db"); @@ -561,8 +561,9 @@ async fn setup_db_before_migration_069() -> (Database, TempDir) { let db = Database::new(&config).await.unwrap(); let conn = db.sea_orm_connection(); - // Run all 66 migrations except the last one (069). Total = 66; running 65 leaves - // 069 pending so the test below can apply it via `Some(1)` and assert before/after. + // Run all migrations through 068 (= 65 entries in the migration list, since + // sequence numbers skip a few). Leaves 069 + 070 pending; the per-migration + // tests apply them with `Some(1)` to step through assertions. Migrator::up(conn, Some(65)).await.unwrap(); (db, temp_dir) @@ -651,3 +652,169 @@ async fn test_migration_069_down_drops_chapter_columns_sqlite() { db.close().await; } + +// -- Migration 070 (backfill_book_volume_chapter) tests -- +// Phase 12 of metadata-count-split: re-parse each book's filename and populate +// `book_metadata.volume` / `chapter` where currently NULL. Idempotent and +// strictly additive — never overwrites a populated value. + +#[tokio::test] +async fn test_migration_070_backfills_from_filename_sqlite() { + let (db, _temp_dir) = setup_db_before_migration_069().await; + let conn = db.sea_orm_connection(); + + // Apply 069 first (adds the columns) so we can populate test rows pre-070. + Migrator::up(conn, Some(1)).await.unwrap(); + assert!(sqlite_has_column(conn, "book_metadata", "chapter").await); + + // Seed library + series + a handful of books covering each parse case. + conn.execute_unprepared( + "INSERT INTO libraries (id, name, path, series_strategy, book_strategy, number_strategy, default_reading_direction, created_at, updated_at) + VALUES (X'00000000000000000000000000000001', 'Lib', '/lib', 'series_volume', 'filename', 'file_order', 'LEFT_TO_RIGHT', '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z')" + ).await.unwrap(); + + conn.execute_unprepared( + "INSERT INTO series (id, library_id, path, name, normalized_name, created_at, updated_at) + VALUES (X'00000000000000000000000000000010', X'00000000000000000000000000000001', '/path', 'S', 's', '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z')" + ).await.unwrap(); + + let cases: &[(&str, &str, &str)] = &[ + // (book_id_hex, file_name, comment) + // Volume only. + ( + "11111111111111111111111111111111", + "Series v01.cbz", + "vol-only", + ), + // Chapter only. + ( + "22222222222222222222222222222222", + "Series c042.cbz", + "chap-only", + ), + // Both. + ( + "33333333333333333333333333333333", + "Series v15 - c126 (2023).cbz", + "both", + ), + // Bare number — neither populated. + ("44444444444444444444444444444444", "Naruto 042.cbz", "bare"), + ]; + + for (id, file_name, _comment) in cases { + conn.execute_unprepared(&format!( + "INSERT INTO books (id, series_id, library_id, file_path, file_name, file_size, file_hash, partial_hash, format, page_count, deleted, analyzed, modified_at, created_at, updated_at) + VALUES (X'{id}', X'00000000000000000000000000000010', X'00000000000000000000000000000001', '/path/{file_name}', '{file_name}', 1024, 'h', '', 'cbz', 10, 0, 0, '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z')" + )).await.unwrap(); + + let metadata_id = format!("aa{}", &id[2..]); + conn.execute_unprepared(&format!( + "INSERT INTO book_metadata (id, book_id, search_title, created_at, updated_at) + VALUES (X'{metadata_id}', X'{id}', '{file_name}', '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z')" + )).await.unwrap(); + } + + // Pre-set volume = 99 for one book — the migration must NOT overwrite this. + let preset_book_id = "55555555555555555555555555555555"; + let preset_meta_id = "bb555555555555555555555555555555"; + conn.execute_unprepared(&format!( + "INSERT INTO books (id, series_id, library_id, file_path, file_name, file_size, file_hash, partial_hash, format, page_count, deleted, analyzed, modified_at, created_at, updated_at) + VALUES (X'{preset_book_id}', X'00000000000000000000000000000010', X'00000000000000000000000000000001', '/path/Series v07.cbz', 'Series v07.cbz', 1024, 'h', '', 'cbz', 10, 0, 0, '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z')" + )).await.unwrap(); + conn.execute_unprepared(&format!( + "INSERT INTO book_metadata (id, book_id, search_title, volume, volume_lock, created_at, updated_at) + VALUES (X'{preset_meta_id}', X'{preset_book_id}', 'Series v07', 99, 0, '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z')" + )).await.unwrap(); + + // Apply migration 070. + Migrator::up(conn, Some(1)).await.unwrap(); + + // Verify each parse case landed correctly. + let expected: &[(&str, Option, Option)] = &[ + ("11111111111111111111111111111111", Some(1), None), + ("22222222222222222222222222222222", None, Some(42.0)), + ("33333333333333333333333333333333", Some(15), Some(126.0)), + ("44444444444444444444444444444444", None, None), + ]; + + for (id, want_vol, want_chap) in expected { + let row = conn + .query_one(Statement::from_string( + DatabaseBackend::Sqlite, + format!("SELECT volume, chapter FROM book_metadata WHERE book_id = X'{id}'"), + )) + .await + .unwrap() + .unwrap(); + let vol: Option = row.try_get("", "volume").unwrap(); + let chap: Option = row.try_get("", "chapter").unwrap(); + assert_eq!(vol, *want_vol, "volume mismatch for {id}"); + assert_eq!(chap, *want_chap, "chapter mismatch for {id}"); + } + + // Pre-set volume must be preserved (additive only — never overwrites). + let row = conn + .query_one(Statement::from_string( + DatabaseBackend::Sqlite, + format!("SELECT volume FROM book_metadata WHERE book_id = X'{preset_book_id}'"), + )) + .await + .unwrap() + .unwrap(); + let vol: Option = row.try_get("", "volume").unwrap(); + assert_eq!( + vol, + Some(99), + "backfill must not overwrite a manually-set volume" + ); + + db.close().await; +} + +#[tokio::test] +async fn test_migration_070_is_idempotent_sqlite() { + let (db, _temp_dir) = setup_db_before_migration_069().await; + let conn = db.sea_orm_connection(); + + // Apply 069. + Migrator::up(conn, Some(1)).await.unwrap(); + + conn.execute_unprepared( + "INSERT INTO libraries (id, name, path, series_strategy, book_strategy, number_strategy, default_reading_direction, created_at, updated_at) + VALUES (X'00000000000000000000000000000001', 'Lib', '/lib', 'series_volume', 'filename', 'file_order', 'LEFT_TO_RIGHT', '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z')" + ).await.unwrap(); + conn.execute_unprepared( + "INSERT INTO series (id, library_id, path, name, normalized_name, created_at, updated_at) + VALUES (X'00000000000000000000000000000010', X'00000000000000000000000000000001', '/path', 'S', 's', '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z')" + ).await.unwrap(); + conn.execute_unprepared( + "INSERT INTO books (id, series_id, library_id, file_path, file_name, file_size, file_hash, partial_hash, format, page_count, deleted, analyzed, modified_at, created_at, updated_at) + VALUES (X'00000000000000000000000000000020', X'00000000000000000000000000000010', X'00000000000000000000000000000001', '/path/Series v05 - c100.cbz', 'Series v05 - c100.cbz', 1024, 'h', '', 'cbz', 10, 0, 0, '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z')" + ).await.unwrap(); + conn.execute_unprepared( + "INSERT INTO book_metadata (id, book_id, search_title, created_at, updated_at) + VALUES (X'00000000000000000000000000000030', X'00000000000000000000000000000020', 'sv05c100', '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z')" + ).await.unwrap(); + + // First pass. + Migrator::up(conn, Some(1)).await.unwrap(); + // Second pass (down + up) — re-running must produce the same result. + Migrator::down(conn, Some(1)).await.unwrap(); + Migrator::up(conn, Some(1)).await.unwrap(); + + let row = conn + .query_one(Statement::from_string( + DatabaseBackend::Sqlite, + "SELECT volume, chapter FROM book_metadata WHERE book_id = X'00000000000000000000000000000020'".to_string(), + )) + .await + .unwrap() + .unwrap(); + let vol: Option = row.try_get("", "volume").unwrap(); + let chap: Option = row.try_get("", "chapter").unwrap(); + assert_eq!(vol, Some(5)); + assert_eq!(chap, Some(100.0)); + + db.close().await; +} diff --git a/tests/scanner/book_naming_strategy.rs b/tests/scanner/book_naming_strategy.rs index 9c229cb7..0cafc03b 100644 --- a/tests/scanner/book_naming_strategy.rs +++ b/tests/scanner/book_naming_strategy.rs @@ -544,3 +544,184 @@ async fn test_custom_book_strategy_persistence() { let title = strategy.resolve_title("One_Piece_v012_c145.cbz", None, &context); assert_eq!(title, "One_Piece v.012 c.145"); } + +// ============================================================================ +// Per-book volume/chapter classification (Phase 12 of metadata-count-split) +// ============================================================================ +// +// These tests cover the strategy x parse-case matrix the scanner now relies on +// to populate `book_metadata.volume` / `book_metadata.chapter`. They verify the +// strategy contract directly (the scanner glue is a thin call into +// `resolve_volume` / `resolve_chapter`); a full end-to-end scan test would only +// re-cover library wiring already tested elsewhere. + +fn classification_context() -> BookNamingContext { + BookNamingContext { + series_name: "Test Series".to_string(), + book_number: None, + volume: None, + chapter_number: None, + total_books: 50, + } +} + +/// Filename strategy: structured parse from filename, ComicInfo ignored. +#[test] +fn test_filename_strategy_resolves_volume_chapter() { + let strategy = create_book_strategy(BookStrategy::Filename, None); + let ctx = classification_context(); + // ComicInfo says volume=99 chapter=999, but Filename strategy ignores it. + let metadata = BookMetadata { + title: None, + number: None, + volume: Some(99), + chapter: Some(999.0), + }; + + assert_eq!( + strategy.resolve_volume("Series v15 - c126.cbz", Some(&metadata), &ctx), + Some(15) + ); + assert_eq!( + strategy.resolve_chapter("Series v15 - c126.cbz", Some(&metadata), &ctx), + Some(126.0) + ); +} + +/// MetadataFirst strategy: ComicInfo only, filename never parsed. +#[test] +fn test_metadata_first_strategy_uses_only_comic_info() { + let strategy = create_book_strategy(BookStrategy::MetadataFirst, None); + let ctx = classification_context(); + // Filename has v15 - c126, but MetadataFirst defers to ComicInfo. + let metadata = BookMetadata { + title: None, + number: None, + volume: Some(7), + chapter: Some(42.0), + }; + + assert_eq!( + strategy.resolve_volume("Series v15 - c126.cbz", Some(&metadata), &ctx), + Some(7) + ); + assert_eq!( + strategy.resolve_chapter("Series v15 - c126.cbz", Some(&metadata), &ctx), + Some(42.0) + ); + + // No ComicInfo: returns None for both. + assert_eq!( + strategy.resolve_volume("Series v15 - c126.cbz", None, &ctx), + None + ); + assert_eq!( + strategy.resolve_chapter("Series v15 - c126.cbz", None, &ctx), + None + ); +} + +/// Smart strategy: ComicInfo first, filename fallback when ComicInfo silent. +#[test] +fn test_smart_strategy_falls_back_to_filename() { + let strategy = create_book_strategy(BookStrategy::Smart, None); + let ctx = classification_context(); + + // ComicInfo populated -> takes precedence. + let with_meta = BookMetadata { + title: None, + number: None, + volume: Some(7), + chapter: Some(42.0), + }; + assert_eq!( + strategy.resolve_volume("Series v15 - c126.cbz", Some(&with_meta), &ctx), + Some(7) + ); + assert_eq!( + strategy.resolve_chapter("Series v15 - c126.cbz", Some(&with_meta), &ctx), + Some(42.0) + ); + + // ComicInfo silent on volume -> filename fallback fills in. + let chapter_only = BookMetadata { + title: None, + number: None, + volume: None, + chapter: Some(42.0), + }; + assert_eq!( + strategy.resolve_volume("Series v15 - c126.cbz", Some(&chapter_only), &ctx), + Some(15) + ); + assert_eq!( + strategy.resolve_chapter("Series v15 - c126.cbz", Some(&chapter_only), &ctx), + Some(42.0) + ); + + // No ComicInfo at all -> filename is the only source. + assert_eq!( + strategy.resolve_volume("Series v15 - c126.cbz", None, &ctx), + Some(15) + ); + assert_eq!( + strategy.resolve_chapter("Series v15 - c126.cbz", None, &ctx), + Some(126.0) + ); +} + +/// SeriesName strategy: passes through whatever series detection populated on +/// the context. No detection -> None. +#[test] +fn test_series_name_strategy_passes_through_context() { + let strategy = create_book_strategy(BookStrategy::SeriesName, None); + + // Detection wrote volume + chapter into the context. + let with_detection = BookNamingContext { + series_name: "Series".to_string(), + book_number: None, + volume: Some("Volume 12".to_string()), + chapter_number: Some(126.0), + total_books: 200, + }; + assert_eq!( + strategy.resolve_volume("Series v99 - c999.cbz", None, &with_detection), + Some(12) + ); + assert_eq!( + strategy.resolve_chapter("Series v99 - c999.cbz", None, &with_detection), + Some(126.0) + ); + + // No detection on context -> None on both axes (filename ignored). + let without = classification_context(); + assert_eq!( + strategy.resolve_volume("Series v15 - c126.cbz", None, &without), + None + ); + assert_eq!( + strategy.resolve_chapter("Series v15 - c126.cbz", None, &without), + None + ); +} + +/// Custom strategy: extracts named groups from the user's regex. +#[test] +fn test_custom_strategy_resolves_volume_chapter_from_named_groups() { + let config = r#"{"pattern":"(?P.+?)_v(?P\\d+)_c(?P\\d+)","fallback":"filename"}"#; + let strategy = create_book_strategy(BookStrategy::Custom, Some(config)); + let ctx = classification_context(); + + assert_eq!( + strategy.resolve_volume("One_Piece_v012_c145.cbz", None, &ctx), + Some(12) + ); + assert_eq!( + strategy.resolve_chapter("One_Piece_v012_c145.cbz", None, &ctx), + Some(145.0) + ); + + // Pattern doesn't match -> None on both. + assert_eq!(strategy.resolve_volume("random.cbz", None, &ctx), None); + assert_eq!(strategy.resolve_chapter("random.cbz", None, &ctx), None); +} diff --git a/tests/services.rs b/tests/services.rs index f062a9d1..854dde56 100644 --- a/tests/services.rs +++ b/tests/services.rs @@ -1,3 +1,4 @@ mod services { + mod book_metadata_apply; mod metadata_apply; } diff --git a/tests/services/book_metadata_apply.rs b/tests/services/book_metadata_apply.rs new file mode 100644 index 00000000..bab6cf28 --- /dev/null +++ b/tests/services/book_metadata_apply.rs @@ -0,0 +1,313 @@ +//! Tests for BookMetadataApplier — Phase 12 of metadata-count-split focus on +//! the new per-book volume / chapter write blocks. Uses the same plugin-permission +//! shape as the series-side `metadata_apply.rs` tests for consistency. + +#[path = "../common/mod.rs"] +mod common; + +use chrono::Utc; +use codex::db::ScanningStrategy; +use codex::db::entities::plugins; +use codex::db::repositories::{ + BookMetadataRepository, BookRepository, LibraryRepository, SeriesRepository, +}; +use codex::services::metadata::{BookApplyOptions, BookMetadataApplier}; +use codex::services::plugin::protocol::PluginBookMetadata; +use common::db::setup_test_db; +use common::fixtures::create_test_book; +use serde_json::json; +use std::collections::HashSet; +use uuid::Uuid; + +fn create_plugin_with_permissions(permissions: &[&str]) -> plugins::Model { + plugins::Model { + id: Uuid::new_v4(), + name: "test-plugin-book".to_string(), + display_name: "Test Plugin Book".to_string(), + description: None, + plugin_type: "system".to_string(), + command: "node".to_string(), + args: json!([]), + env: json!({}), + working_directory: None, + permissions: json!(permissions), + scopes: json!(["book:detail"]), + library_ids: json!([]), + credentials: None, + credential_delivery: "env".to_string(), + config: json!({}), + manifest: None, + enabled: true, + health_status: "healthy".to_string(), + failure_count: 0, + last_failure_at: None, + last_success_at: None, + disabled_reason: None, + rate_limit_requests_per_minute: None, + search_query_template: None, + search_preprocessing_rules: None, + auto_match_conditions: None, + use_existing_external_id: true, + metadata_targets: None, + internal_config: None, + created_at: Utc::now(), + updated_at: Utc::now(), + created_by: None, + updated_by: None, + } +} + +fn book_metadata_with_volume_chapter( + volume: Option, + chapter: Option, +) -> PluginBookMetadata { + PluginBookMetadata { + external_id: "book-1".to_string(), + external_url: "https://example.com/book-1".to_string(), + title: None, + subtitle: None, + alternate_titles: vec![], + summary: None, + book_type: None, + volume, + chapter, + page_count: None, + release_date: None, + year: None, + isbn: None, + isbns: vec![], + edition: None, + original_title: None, + original_year: None, + translator: None, + language: None, + series_position: None, + series_total: None, + genres: vec![], + tags: vec![], + subjects: vec![], + authors: vec![], + artists: vec![], + publisher: None, + cover_url: None, + covers: vec![], + rating: None, + external_ratings: vec![], + awards: vec![], + external_links: vec![], + external_ids: vec![], + } +} + +async fn setup_book(db: &sea_orm::DatabaseConnection) -> codex::db::entities::book_metadata::Model { + let library = LibraryRepository::create(db, "Lib", "/lib", ScanningStrategy::Default) + .await + .unwrap(); + let series = SeriesRepository::create(db, library.id, "Series", None) + .await + .unwrap(); + let book = create_test_book( + series.id, + library.id, + "/lib/Series v01.cbz", + "Series v01.cbz", + "hash", + "cbz", + 10, + ); + BookRepository::create(db, &book, None).await.unwrap(); + BookMetadataRepository::create_with_title_and_number(db, book.id, None, None) + .await + .unwrap() +} + +#[tokio::test] +async fn test_apply_book_volume_writes_value() { + let (db, _temp_dir) = setup_test_db().await; + let current = setup_book(&db).await; + + let plugin = create_plugin_with_permissions(&["metadata:write:volume"]); + let result = BookMetadataApplier::apply( + &db, + current.book_id, + &plugin, + &book_metadata_with_volume_chapter(Some(7.0), None), + Some(¤t), + &BookApplyOptions::default(), + ) + .await + .unwrap(); + + assert!( + result.applied_fields.contains(&"volume".to_string()), + "volume should be applied (got applied={:?}, skipped={:?})", + result.applied_fields, + result.skipped_fields + ); + let updated = BookMetadataRepository::get_by_book_id(&db, current.book_id) + .await + .unwrap() + .unwrap(); + assert_eq!(updated.volume, Some(7)); + assert!(updated.chapter.is_none()); +} + +#[tokio::test] +async fn test_apply_book_chapter_writes_fractional_value() { + let (db, _temp_dir) = setup_test_db().await; + let current = setup_book(&db).await; + + let plugin = create_plugin_with_permissions(&["metadata:write:chapter"]); + let result = BookMetadataApplier::apply( + &db, + current.book_id, + &plugin, + &book_metadata_with_volume_chapter(None, Some(47.5)), + Some(¤t), + &BookApplyOptions::default(), + ) + .await + .unwrap(); + + assert!(result.applied_fields.contains(&"chapter".to_string())); + let updated = BookMetadataRepository::get_by_book_id(&db, current.book_id) + .await + .unwrap() + .unwrap(); + assert_eq!(updated.chapter, Some(47.5)); + assert!(updated.volume.is_none()); +} + +#[tokio::test] +async fn test_apply_book_volume_skipped_when_locked() { + let (db, _temp_dir) = setup_test_db().await; + let current = setup_book(&db).await; + + BookMetadataRepository::set_lock(&db, current.book_id, "volume", true) + .await + .unwrap(); + let locked = BookMetadataRepository::get_by_book_id(&db, current.book_id) + .await + .unwrap() + .unwrap(); + + let plugin = create_plugin_with_permissions(&["metadata:write:volume"]); + let result = BookMetadataApplier::apply( + &db, + current.book_id, + &plugin, + &book_metadata_with_volume_chapter(Some(7.0), None), + Some(&locked), + &BookApplyOptions::default(), + ) + .await + .unwrap(); + + assert!(!result.applied_fields.contains(&"volume".to_string())); + let skipped = result + .skipped_fields + .iter() + .find(|s| s.field == "volume") + .expect("volume must be in skipped"); + assert!(skipped.reason.contains("locked")); + + let updated = BookMetadataRepository::get_by_book_id(&db, current.book_id) + .await + .unwrap() + .unwrap(); + assert!(updated.volume.is_none(), "locked volume must stay null"); +} + +#[tokio::test] +async fn test_apply_book_chapter_skipped_when_permission_missing() { + let (db, _temp_dir) = setup_test_db().await; + let current = setup_book(&db).await; + + // Plugin has volume permission but NOT chapter — chapter must be skipped. + let plugin = create_plugin_with_permissions(&["metadata:write:volume"]); + let result = BookMetadataApplier::apply( + &db, + current.book_id, + &plugin, + &book_metadata_with_volume_chapter(Some(7.0), Some(42.0)), + Some(¤t), + &BookApplyOptions::default(), + ) + .await + .unwrap(); + + assert!(result.applied_fields.contains(&"volume".to_string())); + assert!(!result.applied_fields.contains(&"chapter".to_string())); + let skipped = result + .skipped_fields + .iter() + .find(|s| s.field == "chapter") + .expect("chapter must be in skipped"); + assert!(skipped.reason.contains("permission")); +} + +#[tokio::test] +async fn test_apply_book_fractional_volume_rejected() { + let (db, _temp_dir) = setup_test_db().await; + let current = setup_book(&db).await; + + let plugin = create_plugin_with_permissions(&["metadata:write:volume"]); + let result = BookMetadataApplier::apply( + &db, + current.book_id, + &plugin, + &book_metadata_with_volume_chapter(Some(1.5), None), + Some(¤t), + &BookApplyOptions::default(), + ) + .await + .unwrap(); + + assert!(!result.applied_fields.contains(&"volume".to_string())); + let skipped = result + .skipped_fields + .iter() + .find(|s| s.field == "volume") + .expect("volume must be skipped for fractional"); + assert!( + skipped.reason.to_lowercase().contains("fractional"), + "reason should mention fractional rejection: {}", + skipped.reason + ); +} + +#[tokio::test] +async fn test_apply_book_volume_chapter_filtered_by_allowlist() { + let (db, _temp_dir) = setup_test_db().await; + let current = setup_book(&db).await; + + let plugin = + create_plugin_with_permissions(&["metadata:write:volume", "metadata:write:chapter"]); + // Allowlist: only chapter — volume should not be touched even though + // permission + value are present. + let mut filter = HashSet::new(); + filter.insert("chapter".to_string()); + let options = BookApplyOptions { + fields_filter: Some(filter), + ..BookApplyOptions::default() + }; + let result = BookMetadataApplier::apply( + &db, + current.book_id, + &plugin, + &book_metadata_with_volume_chapter(Some(7.0), Some(42.0)), + Some(¤t), + &options, + ) + .await + .unwrap(); + + assert!(result.applied_fields.contains(&"chapter".to_string())); + assert!(!result.applied_fields.contains(&"volume".to_string())); + let updated = BookMetadataRepository::get_by_book_id(&db, current.book_id) + .await + .unwrap() + .unwrap(); + assert!(updated.volume.is_none()); + assert_eq!(updated.chapter, Some(42.0)); +} From 5cd6cf0e66ab16c3f45f4e57f05abb555e969973 Mon Sep 17 00:00:00 2001 From: Sylvain Cau Date: Sat, 2 May 2026 21:07:12 -0700 Subject: [PATCH 14/19] feat(series): expose local volume/chapter aggregates and switch count display to / MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds per-series aggregates derived from book_metadata.volume / chapter so the series detail header can render `14/17 vol · 137/158 ch` instead of the file-count-based `17/17 vol`, which was incoherent for libraries that mix complete volumes with loose chapters. - Repo: new BookClassificationAggregates struct and get_book_classification_aggregates{,_for_series_ids} on SeriesRepository. Single SQL pass: MAX(volume), MAX(chapter), and SUM(CASE WHEN volume IS NOT NULL AND chapter IS NULL THEN 1 ELSE 0) over books LEFT JOIN book_metadata grouped by series_id. Works on SQLite and PostgreSQL. - DTOs: SeriesDto and FullSeriesResponse gain optional localMaxVolume, localMaxChapter, and volumesOwned (skipped when None, schema-documented). Both the single-series helper and the batched variant feed the new aggregator (added to the existing tokio::join! block so list endpoints stay one round-trip). - Frontend: formatSeriesCounts gains optional localMaxVolume and localMaxChapter inputs. When present they replace the file-count numerator on each axis; when absent the legacy formatting is preserved verbatim. Wired into SeriesDetail. - OpenAPI regenerated; new fields land in the TypeScript client types. Tests cover mixed/empty/unclassified series at the repo layer, the DTO round-trip via /api/v1/series/{id}, and the new formatter branches alongside every existing case. --- docs/api/openapi.json | 81 +++++ src/api/routes/v1/dto/series.rs | 47 +++ src/api/routes/v1/handlers/series.rs | 28 ++ src/db/repositories/series.rs | 327 +++++++++++++++++- tests/api/series.rs | 104 +++++- web/openapi.json | 81 +++++ .../components/series/seriesCounts.test.ts | 58 ++++ web/src/components/series/seriesCounts.ts | 77 ++++- web/src/pages/SeriesDetail.tsx | 2 + web/src/types/api.generated.ts | 85 +++++ 10 files changed, 872 insertions(+), 18 deletions(-) diff --git a/docs/api/openapi.json b/docs/api/openapi.json index 33d0124a..6831ef96 100644 --- a/docs/api/openapi.json +++ b/docs/api/openapi.json @@ -23435,6 +23435,24 @@ "description": "Name of the library this series belongs to", "example": "Comics" }, + "localMaxChapter": { + "type": [ + "number", + "null" + ], + "format": "float", + "description": "Highest `book_metadata.chapter` across the books in this series.\nSee `SeriesDto::local_max_chapter` for semantics.", + "example": 137.5 + }, + "localMaxVolume": { + "type": [ + "integer", + "null" + ], + "format": "int32", + "description": "Highest `book_metadata.volume` across the books in this series.\nSee `SeriesDto::local_max_volume` for semantics.", + "example": 14 + }, "metadata": { "$ref": "#/components/schemas/SeriesFullMetadata", "description": "Complete series metadata" @@ -23476,6 +23494,15 @@ "format": "date-time", "description": "When the series was last updated", "example": "2024-01-15T10:30:00Z" + }, + "volumesOwned": { + "type": [ + "integer", + "null" + ], + "format": "int64", + "description": "Number of books classified as a complete volume (volume set, chapter null).\nSee `SeriesDto::volumes_owned` for semantics.", + "example": 14 } } }, @@ -27883,6 +27910,24 @@ "description": "Name of the library this series belongs to", "example": "Comics" }, + "localMaxChapter": { + "type": [ + "number", + "null" + ], + "format": "float", + "description": "Highest `book_metadata.chapter` across the books in this series.\n\n`None` when no book in the series has `chapter` populated. When\nnon-null and `metadata.totalChapterCount` is also known, the UI renders\n`/ ch`.", + "example": 137.5 + }, + "localMaxVolume": { + "type": [ + "integer", + "null" + ], + "format": "int32", + "description": "Highest `book_metadata.volume` across the books in this series.\n\n`None` when no book in the series has `volume` populated. When\nnon-null and `metadata.totalVolumeCount` is also known, the UI renders\n`/ vol` instead of the legacy\n`/ vol`.", + "example": 14 + }, "path": { "type": [ "string", @@ -27943,6 +27988,15 @@ "description": "When the series was last updated", "example": "2024-01-15T10:30:00Z" }, + "volumesOwned": { + "type": [ + "integer", + "null" + ], + "format": "int64", + "description": "Number of books in this series classified as a complete volume\n(`volume IS NOT NULL AND chapter IS NULL`).\n\nDistinct from `bookCount`: a chapter inside a volume (`v15 c126`)\ncounts as a chapter, not a volume. `None` when no books exist;\n`Some(0)` when books exist but none are complete volumes.", + "example": 14 + }, "year": { "type": [ "integer", @@ -32214,6 +32268,24 @@ "description": "Name of the library this series belongs to", "example": "Comics" }, + "localMaxChapter": { + "type": [ + "number", + "null" + ], + "format": "float", + "description": "Highest `book_metadata.chapter` across the books in this series.\n\n`None` when no book in the series has `chapter` populated. When\nnon-null and `metadata.totalChapterCount` is also known, the UI renders\n`/ ch`.", + "example": 137.5 + }, + "localMaxVolume": { + "type": [ + "integer", + "null" + ], + "format": "int32", + "description": "Highest `book_metadata.volume` across the books in this series.\n\n`None` when no book in the series has `volume` populated. When\nnon-null and `metadata.totalVolumeCount` is also known, the UI renders\n`/ vol` instead of the legacy\n`/ vol`.", + "example": 14 + }, "path": { "type": [ "string", @@ -32274,6 +32346,15 @@ "description": "When the series was last updated", "example": "2024-01-15T10:30:00Z" }, + "volumesOwned": { + "type": [ + "integer", + "null" + ], + "format": "int64", + "description": "Number of books in this series classified as a complete volume\n(`volume IS NOT NULL AND chapter IS NULL`).\n\nDistinct from `bookCount`: a chapter inside a volume (`v15 c126`)\ncounts as a chapter, not a volume. `None` when no books exist;\n`Some(0)` when books exist but none are complete volumes.", + "example": 14 + }, "year": { "type": [ "integer", diff --git a/src/api/routes/v1/dto/series.rs b/src/api/routes/v1/dto/series.rs index b2547af7..40d174ff 100644 --- a/src/api/routes/v1/dto/series.rs +++ b/src/api/routes/v1/dto/series.rs @@ -200,6 +200,35 @@ pub struct SeriesDto { #[schema(example = 4)] pub book_count: i64, + /// Highest `book_metadata.volume` across the books in this series. + /// + /// `None` when no book in the series has `volume` populated. When + /// non-null and `metadata.totalVolumeCount` is also known, the UI renders + /// `/ vol` instead of the legacy + /// `/ vol`. + #[serde(skip_serializing_if = "Option::is_none")] + #[schema(example = 14)] + pub local_max_volume: Option, + + /// Highest `book_metadata.chapter` across the books in this series. + /// + /// `None` when no book in the series has `chapter` populated. When + /// non-null and `metadata.totalChapterCount` is also known, the UI renders + /// `/ ch`. + #[serde(skip_serializing_if = "Option::is_none")] + #[schema(example = 137.5)] + pub local_max_chapter: Option, + + /// Number of books in this series classified as a complete volume + /// (`volume IS NOT NULL AND chapter IS NULL`). + /// + /// Distinct from `bookCount`: a chapter inside a volume (`v15 c126`) + /// counts as a chapter, not a volume. `None` when no books exist; + /// `Some(0)` when books exist but none are complete volumes. + #[serde(skip_serializing_if = "Option::is_none")] + #[schema(example = 14)] + pub volumes_owned: Option, + /// Filesystem path to the series directory #[serde(skip_serializing_if = "Option::is_none")] #[schema(example = "/media/comics/Batman - Year One")] @@ -1218,6 +1247,24 @@ pub struct FullSeriesResponse { #[schema(example = 4)] pub book_count: i64, + /// Highest `book_metadata.volume` across the books in this series. + /// See `SeriesDto::local_max_volume` for semantics. + #[serde(skip_serializing_if = "Option::is_none")] + #[schema(example = 14)] + pub local_max_volume: Option, + + /// Highest `book_metadata.chapter` across the books in this series. + /// See `SeriesDto::local_max_chapter` for semantics. + #[serde(skip_serializing_if = "Option::is_none")] + #[schema(example = 137.5)] + pub local_max_chapter: Option, + + /// Number of books classified as a complete volume (volume set, chapter null). + /// See `SeriesDto::volumes_owned` for semantics. + #[serde(skip_serializing_if = "Option::is_none")] + #[schema(example = 14)] + pub volumes_owned: Option, + /// Number of unread books in this series (user-specific) #[serde(skip_serializing_if = "Option::is_none")] #[schema(example = 2)] diff --git a/src/api/routes/v1/handlers/series.rs b/src/api/routes/v1/handlers/series.rs index f884b8c3..d9cf6140 100644 --- a/src/api/routes/v1/handlers/series.rs +++ b/src/api/routes/v1/handlers/series.rs @@ -147,6 +147,16 @@ async fn series_to_dto( .await .map_err(|e| ApiError::Internal(format!("Failed to get book count: {:?}", e)))?; + // Per-book classification aggregates derived from book_metadata.volume / chapter + let aggregates = SeriesRepository::get_book_classification_aggregates(db, series.id) + .await + .map_err(|e| { + ApiError::Internal(format!( + "Failed to compute book classification aggregates: {:?}", + e + )) + })?; + // Fetch cover info from series_covers table let selected_cover = SeriesCoversRepository::get_selected(db, series.id) .await @@ -180,6 +190,9 @@ async fn series_to_dto( publisher: metadata.as_ref().and_then(|m| m.publisher.clone()), year: metadata.as_ref().and_then(|m| m.year), book_count, + local_max_volume: aggregates.local_max_volume, + local_max_chapter: aggregates.local_max_chapter, + volumes_owned: aggregates.volumes_owned, path: Some(series.path), selected_cover_source: selected_cover.map(|c| c.source), has_custom_cover: Some(has_custom_cover), @@ -217,6 +230,7 @@ async fn series_to_full_dtos_batched( let ( metadata_map, book_counts_map, + classification_map, unread_counts_map, selected_covers_map, custom_covers_map, @@ -230,6 +244,7 @@ async fn series_to_full_dtos_batched( ) = tokio::join!( SeriesMetadataRepository::get_by_series_ids(db, &series_ids), SeriesRepository::get_book_counts_for_series_ids(db, &series_ids), + SeriesRepository::get_book_classification_aggregates_for_series_ids(db, &series_ids), async { if let Some(uid) = user_id { BookRepository::count_unread_in_series_ids(db, &series_ids, uid).await @@ -253,6 +268,12 @@ async fn series_to_full_dtos_batched( metadata_map.map_err(|e| ApiError::Internal(format!("Failed to fetch metadata: {}", e)))?; let book_counts_map = book_counts_map .map_err(|e| ApiError::Internal(format!("Failed to get book counts: {}", e)))?; + let classification_map = classification_map.map_err(|e| { + ApiError::Internal(format!( + "Failed to compute book classification aggregates: {}", + e + )) + })?; let unread_counts_map = unread_counts_map .map_err(|e| ApiError::Internal(format!("Failed to count unread: {}", e)))?; let selected_covers_map = selected_covers_map @@ -287,6 +308,10 @@ async fn series_to_full_dtos_batched( // Get other data (with defaults) let book_count = book_counts_map.get(&series_id).copied().unwrap_or(0); + let aggregates = classification_map + .get(&series_id) + .copied() + .unwrap_or_default(); let unread_count = unread_counts_map.get(&series_id).copied(); let selected_cover = selected_covers_map.get(&series_id); let has_custom_cover = custom_covers_map.get(&series_id).copied().unwrap_or(false); @@ -395,6 +420,9 @@ async fn series_to_full_dtos_batched( library_id: series.library_id, library_name, book_count, + local_max_volume: aggregates.local_max_volume, + local_max_chapter: aggregates.local_max_chapter, + volumes_owned: aggregates.volumes_owned, unread_count, path: Some(series.path), selected_cover_source: selected_cover.map(|c| c.source.clone()), diff --git a/src/db/repositories/series.rs b/src/db/repositories/series.rs index 995d0c8f..e7c0bc63 100644 --- a/src/db/repositories/series.rs +++ b/src/db/repositories/series.rs @@ -15,8 +15,8 @@ use uuid::Uuid; use crate::api::routes::v1::dto::series::{SeriesSortField, SeriesSortParam, SortDirection}; use crate::db::entities::{ - books, prelude::*, read_progress, series, series_external_ratings, series_metadata, - user_series_ratings, + book_metadata, books, prelude::*, read_progress, series, series_external_ratings, + series_metadata, user_series_ratings, }; use crate::events::{EntityChangeEvent, EntityEvent, EventBroadcaster}; use crate::utils::normalize_for_search; @@ -193,6 +193,25 @@ impl From for series::Model { } } +/// Per-series aggregates derived from `book_metadata.volume` and +/// `book_metadata.chapter`. +/// +/// Used by series DTOs to render `/` counts when structured +/// data has been populated by the scanner (Phase 12). All fields are +/// `None` when no books in the series have the underlying value set, +/// keeping the legacy `/` display intact for +/// unclassified libraries. +#[derive(Debug, Clone, Copy, Default)] +pub struct BookClassificationAggregates { + /// Highest `book_metadata.volume` across non-deleted books in the series. + pub local_max_volume: Option, + /// Highest `book_metadata.chapter` across non-deleted books in the series. + pub local_max_chapter: Option, + /// Number of books in the series with `volume IS NOT NULL AND chapter IS NULL` + /// (strict-volume count: a "complete" volume rather than a chapter inside one). + pub volumes_owned: Option, +} + /// Repository for Series operations pub struct SeriesRepository; @@ -1958,6 +1977,96 @@ impl SeriesRepository { Ok(map) } + /// Get classification aggregates for a single series. + /// + /// Wrapper around `get_book_classification_aggregates_for_series_ids` for + /// the single-series detail endpoint. + pub async fn get_book_classification_aggregates( + db: &DatabaseConnection, + series_id: Uuid, + ) -> Result { + let map = Self::get_book_classification_aggregates_for_series_ids(db, &[series_id]).await?; + Ok(map.get(&series_id).copied().unwrap_or_default()) + } + + /// Compute `local_max_volume`, `local_max_chapter`, and `volumes_owned` + /// for multiple series in one round-trip. + /// + /// All series IDs in `series_ids` are present in the returned map; series + /// with no classified books map to a default (all-`None`) aggregate so + /// callers don't need to handle missing keys. + pub async fn get_book_classification_aggregates_for_series_ids( + db: &DatabaseConnection, + series_ids: &[Uuid], + ) -> Result> { + use sea_orm::{FromQueryResult, QuerySelect, sea_query::Expr}; + + if series_ids.is_empty() { + return Ok(std::collections::HashMap::new()); + } + + #[derive(Debug, FromQueryResult)] + struct AggRow { + series_id: Uuid, + local_max_volume: Option, + local_max_chapter: Option, + volumes_owned: Option, + } + + // SUM(CASE WHEN volume IS NOT NULL AND chapter IS NULL THEN 1 ELSE 0 END) + // counts books that are "complete" volumes (no chapter pinned). Works + // identically on SQLite and PostgreSQL. + let volumes_owned_expr = Expr::case( + Expr::col((book_metadata::Entity, book_metadata::Column::Volume)) + .is_not_null() + .and(Expr::col((book_metadata::Entity, book_metadata::Column::Chapter)).is_null()), + 1, + ) + .finally(0); + + let results: Vec = books::Entity::find() + .select_only() + .column(books::Column::SeriesId) + .join(JoinType::LeftJoin, books::Relation::BookMetadata.def()) + .column_as( + Expr::col((book_metadata::Entity, book_metadata::Column::Volume)).max(), + "local_max_volume", + ) + .column_as( + Expr::col((book_metadata::Entity, book_metadata::Column::Chapter)).max(), + "local_max_chapter", + ) + .column_as(Expr::expr(volumes_owned_expr).sum(), "volumes_owned") + .filter(books::Column::SeriesId.is_in(series_ids.to_vec())) + .filter(books::Column::Deleted.eq(false)) + .group_by(books::Column::SeriesId) + .into_model::() + .all(db) + .await + .context("Failed to aggregate book classification fields")?; + + let mut map: std::collections::HashMap = results + .into_iter() + .map(|r| { + ( + r.series_id, + BookClassificationAggregates { + local_max_volume: r.local_max_volume, + local_max_chapter: r.local_max_chapter, + volumes_owned: r.volumes_owned, + }, + ) + }) + .collect(); + + // Fill in defaults for series with no books + for id in series_ids { + map.entry(*id).or_default(); + } + + Ok(map) + } + /// Delete a series pub async fn delete(db: &DatabaseConnection, id: Uuid) -> Result<()> { Series::delete_by_id(id) @@ -3280,4 +3389,218 @@ mod tests { .unwrap(); assert_eq!(metadata.title, "One Piece"); } + + /// Helper to create a book + book_metadata row with a configured + /// `(volume, chapter)` pair for aggregation tests. + async fn insert_book_with_classification( + db: &sea_orm::DatabaseConnection, + series_id: Uuid, + library_id: Uuid, + path: &str, + volume: Option, + chapter: Option, + ) -> Uuid { + use crate::db::repositories::BookMetadataRepository; + use sea_orm::{ActiveModelTrait, Set}; + + let book = books::Model { + id: Uuid::new_v4(), + series_id, + library_id, + file_path: path.to_string(), + file_name: path.rsplit('/').next().unwrap_or(path).to_string(), + file_size: 1024, + file_hash: format!("hash_{}", Uuid::new_v4()), + partial_hash: String::new(), + format: "cbz".to_string(), + page_count: 10, + deleted: false, + analyzed: false, + analysis_error: None, + analysis_errors: None, + modified_at: Utc::now(), + created_at: Utc::now(), + updated_at: Utc::now(), + thumbnail_path: None, + thumbnail_generated_at: None, + koreader_hash: None, + epub_positions: None, + epub_spine_items: None, + }; + let created = BookRepository::create(db, &book, None).await.unwrap(); + let meta = BookMetadataRepository::create_with_title_and_number(db, created.id, None, None) + .await + .unwrap(); + // Patch volume / chapter directly. + let mut active: book_metadata::ActiveModel = meta.into(); + active.volume = Set(volume); + active.chapter = Set(chapter); + active.update(db).await.unwrap(); + created.id + } + + #[tokio::test] + async fn test_classification_aggregates_mixed_books() { + let (db, _temp_dir) = create_test_db().await; + let conn = db.sea_orm_connection(); + + let library = LibraryRepository::create( + conn, + "Test Library", + "/test/path", + ScanningStrategy::Default, + ) + .await + .unwrap(); + + let series = SeriesRepository::create(conn, library.id, "Vinland Saga", None) + .await + .unwrap(); + + // Volumes 1, 2, 14 (no chapter pinned -> count toward volumes_owned). + insert_book_with_classification(conn, series.id, library.id, "/v1.cbz", Some(1), None) + .await; + insert_book_with_classification(conn, series.id, library.id, "/v2.cbz", Some(2), None) + .await; + insert_book_with_classification(conn, series.id, library.id, "/v14.cbz", Some(14), None) + .await; + // Chapter inside a volume (v15 c126) — does NOT count toward volumes_owned + // but contributes to local_max_volume and local_max_chapter. + insert_book_with_classification( + conn, + series.id, + library.id, + "/v15-c126.cbz", + Some(15), + Some(126.0), + ) + .await; + // Pure chapter (c137) — only contributes to local_max_chapter. + insert_book_with_classification( + conn, + series.id, + library.id, + "/c137.cbz", + None, + Some(137.5), + ) + .await; + + let agg = SeriesRepository::get_book_classification_aggregates(conn, series.id) + .await + .unwrap(); + + assert_eq!(agg.local_max_volume, Some(15)); + assert_eq!(agg.local_max_chapter, Some(137.5)); + assert_eq!(agg.volumes_owned, Some(3)); + } + + #[tokio::test] + async fn test_classification_aggregates_empty_series() { + let (db, _temp_dir) = create_test_db().await; + let conn = db.sea_orm_connection(); + + let library = LibraryRepository::create( + conn, + "Test Library", + "/test/path", + ScanningStrategy::Default, + ) + .await + .unwrap(); + + let empty = SeriesRepository::create(conn, library.id, "Empty", None) + .await + .unwrap(); + + let agg = SeriesRepository::get_book_classification_aggregates(conn, empty.id) + .await + .unwrap(); + + assert_eq!(agg.local_max_volume, None); + assert_eq!(agg.local_max_chapter, None); + assert_eq!(agg.volumes_owned, None); + } + + #[tokio::test] + async fn test_classification_aggregates_unclassified_books_yield_none() { + let (db, _temp_dir) = create_test_db().await; + let conn = db.sea_orm_connection(); + + let library = LibraryRepository::create( + conn, + "Test Library", + "/test/path", + ScanningStrategy::Default, + ) + .await + .unwrap(); + + let series = SeriesRepository::create(conn, library.id, "Unclassified", None) + .await + .unwrap(); + + // Books exist but have no volume/chapter populated — DTO should fall + // back to legacy `/` formatting. + for i in 1..=3 { + insert_book_with_classification( + conn, + series.id, + library.id, + &format!("/u{}.cbz", i), + None, + None, + ) + .await; + } + + let agg = SeriesRepository::get_book_classification_aggregates(conn, series.id) + .await + .unwrap(); + + assert_eq!(agg.local_max_volume, None); + assert_eq!(agg.local_max_chapter, None); + // SUM over 3 rows of CASE...ELSE 0 = 0, not NULL, so we expect Some(0). + assert_eq!(agg.volumes_owned, Some(0)); + } + + #[tokio::test] + async fn test_classification_aggregates_batch_query_all_series_present() { + let (db, _temp_dir) = create_test_db().await; + let conn = db.sea_orm_connection(); + + let library = LibraryRepository::create( + conn, + "Test Library", + "/test/path", + ScanningStrategy::Default, + ) + .await + .unwrap(); + + let with_books = SeriesRepository::create(conn, library.id, "Has Books", None) + .await + .unwrap(); + insert_book_with_classification(conn, with_books.id, library.id, "/v1.cbz", Some(1), None) + .await; + + let empty = SeriesRepository::create(conn, library.id, "Empty", None) + .await + .unwrap(); + + let map = SeriesRepository::get_book_classification_aggregates_for_series_ids( + conn, + &[with_books.id, empty.id], + ) + .await + .unwrap(); + + // Both series IDs should be present in the map; missing rows get defaults. + assert_eq!(map.len(), 2); + let with = map.get(&with_books.id).copied().unwrap(); + assert_eq!(with.local_max_volume, Some(1)); + let none = map.get(&empty.id).copied().unwrap(); + assert_eq!(none.local_max_volume, None); + assert_eq!(none.volumes_owned, None); + } } diff --git a/tests/api/series.rs b/tests/api/series.rs index bcc10f51..3a5d9427 100644 --- a/tests/api/series.rs +++ b/tests/api/series.rs @@ -6,7 +6,8 @@ use codex::api::routes::v1::dto::book::BookDto; use codex::api::routes::v1::dto::series::{SearchSeriesRequest, SeriesDto, SeriesListResponse}; use codex::db::ScanningStrategy; use codex::db::repositories::{ - BookRepository, LibraryRepository, SeriesMetadataRepository, SeriesRepository, UserRepository, + BookMetadataRepository, BookRepository, LibraryRepository, SeriesMetadataRepository, + SeriesRepository, UserRepository, }; use codex::utils::password; use common::*; @@ -593,6 +594,107 @@ async fn test_get_series_by_id() { assert_eq!(retrieved.title, "Test Series"); } +#[tokio::test] +async fn test_get_series_exposes_classification_aggregates() { + use sea_orm::{ActiveModelTrait, Set}; + + let (db, _temp_dir) = setup_test_db().await; + + let library = LibraryRepository::create(&db, "Library", "/lib", ScanningStrategy::Default) + .await + .unwrap(); + let series = SeriesRepository::create(&db, library.id, "Vinland Saga", None) + .await + .unwrap(); + + // Helper: create a book + classified metadata. + async fn add_classified_book( + db: &sea_orm::DatabaseConnection, + series_id: uuid::Uuid, + library_id: uuid::Uuid, + path: &str, + volume: Option, + chapter: Option, + ) { + let mut book = create_test_book( + series_id, + library_id, + path, + path.rsplit('/').next().unwrap_or(path), + None, + ); + book.file_hash = format!("hash_{}", uuid::Uuid::new_v4()); + let created = BookRepository::create(db, &book, None).await.unwrap(); + let meta = BookMetadataRepository::create_with_title_and_number(db, created.id, None, None) + .await + .unwrap(); + let mut active: codex::db::entities::book_metadata::ActiveModel = meta.into(); + active.volume = Set(volume); + active.chapter = Set(chapter); + active.update(db).await.unwrap(); + } + + add_classified_book(&db, series.id, library.id, "/v1.cbz", Some(1), None).await; + add_classified_book(&db, series.id, library.id, "/v14.cbz", Some(14), None).await; + add_classified_book( + &db, + series.id, + library.id, + "/v15-c126.cbz", + Some(15), + Some(126.0), + ) + .await; + + let state = create_test_auth_state(db.clone()).await; + let token = create_admin_and_token(&db, &state).await; + let app = create_test_router(state).await; + + let request = get_request_with_auth(&format!("/api/v1/series/{}", series.id), &token); + let (status, response): (StatusCode, Option) = make_json_request(app, request).await; + + assert_eq!(status, StatusCode::OK); + let dto = response.unwrap(); + assert_eq!(dto.book_count, 3); + assert_eq!(dto.local_max_volume, Some(15)); + assert_eq!(dto.local_max_chapter, Some(126.0)); + // Two books have volume set with no chapter; the v15+c126 row counts as a chapter. + assert_eq!(dto.volumes_owned, Some(2)); +} + +#[tokio::test] +async fn test_get_series_classification_aggregates_absent_when_unclassified() { + let (db, _temp_dir) = setup_test_db().await; + + let library = LibraryRepository::create(&db, "Library", "/lib", ScanningStrategy::Default) + .await + .unwrap(); + let series = SeriesRepository::create(&db, library.id, "Unclassified", None) + .await + .unwrap(); + // Add a book with no classification. + let mut book = create_test_book(series.id, library.id, "/u1.cbz", "u1.cbz", None); + book.file_hash = "hash_unclassified_1".to_string(); + 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(state).await; + + let request = get_request_with_auth(&format!("/api/v1/series/{}", series.id), &token); + let (status, response): (StatusCode, Option) = make_json_request(app, request).await; + + assert_eq!(status, StatusCode::OK); + let dto = response.unwrap(); + assert_eq!(dto.book_count, 1); + // Book has no metadata row at all -> MAX(volume) and MAX(chapter) are NULL. + assert_eq!(dto.local_max_volume, None); + assert_eq!(dto.local_max_chapter, None); + // SUM(CASE ... ELSE 0) over a single LEFT-JOINed-with-NULL row evaluates + // to 0 (the CASE branch is the constant 0), so volumes_owned is Some(0). + assert_eq!(dto.volumes_owned, Some(0)); +} + #[tokio::test] async fn test_get_series_not_found() { let (db, _temp_dir) = setup_test_db().await; diff --git a/web/openapi.json b/web/openapi.json index 33d0124a..6831ef96 100644 --- a/web/openapi.json +++ b/web/openapi.json @@ -23435,6 +23435,24 @@ "description": "Name of the library this series belongs to", "example": "Comics" }, + "localMaxChapter": { + "type": [ + "number", + "null" + ], + "format": "float", + "description": "Highest `book_metadata.chapter` across the books in this series.\nSee `SeriesDto::local_max_chapter` for semantics.", + "example": 137.5 + }, + "localMaxVolume": { + "type": [ + "integer", + "null" + ], + "format": "int32", + "description": "Highest `book_metadata.volume` across the books in this series.\nSee `SeriesDto::local_max_volume` for semantics.", + "example": 14 + }, "metadata": { "$ref": "#/components/schemas/SeriesFullMetadata", "description": "Complete series metadata" @@ -23476,6 +23494,15 @@ "format": "date-time", "description": "When the series was last updated", "example": "2024-01-15T10:30:00Z" + }, + "volumesOwned": { + "type": [ + "integer", + "null" + ], + "format": "int64", + "description": "Number of books classified as a complete volume (volume set, chapter null).\nSee `SeriesDto::volumes_owned` for semantics.", + "example": 14 } } }, @@ -27883,6 +27910,24 @@ "description": "Name of the library this series belongs to", "example": "Comics" }, + "localMaxChapter": { + "type": [ + "number", + "null" + ], + "format": "float", + "description": "Highest `book_metadata.chapter` across the books in this series.\n\n`None` when no book in the series has `chapter` populated. When\nnon-null and `metadata.totalChapterCount` is also known, the UI renders\n`/ ch`.", + "example": 137.5 + }, + "localMaxVolume": { + "type": [ + "integer", + "null" + ], + "format": "int32", + "description": "Highest `book_metadata.volume` across the books in this series.\n\n`None` when no book in the series has `volume` populated. When\nnon-null and `metadata.totalVolumeCount` is also known, the UI renders\n`/ vol` instead of the legacy\n`/ vol`.", + "example": 14 + }, "path": { "type": [ "string", @@ -27943,6 +27988,15 @@ "description": "When the series was last updated", "example": "2024-01-15T10:30:00Z" }, + "volumesOwned": { + "type": [ + "integer", + "null" + ], + "format": "int64", + "description": "Number of books in this series classified as a complete volume\n(`volume IS NOT NULL AND chapter IS NULL`).\n\nDistinct from `bookCount`: a chapter inside a volume (`v15 c126`)\ncounts as a chapter, not a volume. `None` when no books exist;\n`Some(0)` when books exist but none are complete volumes.", + "example": 14 + }, "year": { "type": [ "integer", @@ -32214,6 +32268,24 @@ "description": "Name of the library this series belongs to", "example": "Comics" }, + "localMaxChapter": { + "type": [ + "number", + "null" + ], + "format": "float", + "description": "Highest `book_metadata.chapter` across the books in this series.\n\n`None` when no book in the series has `chapter` populated. When\nnon-null and `metadata.totalChapterCount` is also known, the UI renders\n`/ ch`.", + "example": 137.5 + }, + "localMaxVolume": { + "type": [ + "integer", + "null" + ], + "format": "int32", + "description": "Highest `book_metadata.volume` across the books in this series.\n\n`None` when no book in the series has `volume` populated. When\nnon-null and `metadata.totalVolumeCount` is also known, the UI renders\n`/ vol` instead of the legacy\n`/ vol`.", + "example": 14 + }, "path": { "type": [ "string", @@ -32274,6 +32346,15 @@ "description": "When the series was last updated", "example": "2024-01-15T10:30:00Z" }, + "volumesOwned": { + "type": [ + "integer", + "null" + ], + "format": "int64", + "description": "Number of books in this series classified as a complete volume\n(`volume IS NOT NULL AND chapter IS NULL`).\n\nDistinct from `bookCount`: a chapter inside a volume (`v15 c126`)\ncounts as a chapter, not a volume. `None` when no books exist;\n`Some(0)` when books exist but none are complete volumes.", + "example": 14 + }, "year": { "type": [ "integer", diff --git a/web/src/components/series/seriesCounts.test.ts b/web/src/components/series/seriesCounts.test.ts index 3f68407e..997d8a44 100644 --- a/web/src/components/series/seriesCounts.test.ts +++ b/web/src/components/series/seriesCounts.test.ts @@ -101,4 +101,62 @@ describe("formatSeriesCounts", () => { }), ).toBe("0/0 vol"); }); + + // Phase 13: localMax* fields override the file-count numerator when the + // scanner has populated structured book_metadata.volume / chapter values. + it("uses localMaxVolume as the volume numerator when present", () => { + expect( + formatSeriesCounts({ + localCount: 17, // 17 files on disk (v01..v15 plus chapter files) + totalVolumeCount: 17, + totalChapterCount: null, + localMaxVolume: 14, // last *complete* volume seen + }), + ).toBe("14/17 vol"); + }); + + it("uses localMaxChapter as the chapter numerator when present", () => { + expect( + formatSeriesCounts({ + localCount: 60, + totalVolumeCount: null, + totalChapterCount: 158, + localMaxChapter: 137, + }), + ).toBe("137/158 ch"); + }); + + it("uses both localMax fields together for a mixed series", () => { + expect( + formatSeriesCounts({ + localCount: 20, + totalVolumeCount: 17, + totalChapterCount: 158, + localMaxVolume: 14, + localMaxChapter: 137, + }), + ).toBe("14/17 vol · 137/158 ch"); + }); + + it("falls back to file-count numerator when localMax fields are absent", () => { + expect( + formatSeriesCounts({ + localCount: 5, + totalVolumeCount: 14, + totalChapterCount: null, + localMaxVolume: null, + }), + ).toBe("5/14 vol"); + }); + + it("preserves fractional localMaxChapter values", () => { + expect( + formatSeriesCounts({ + localCount: 1, + totalVolumeCount: null, + totalChapterCount: 158, + localMaxChapter: 137.5, + }), + ).toBe("137.5/158 ch"); + }); }); diff --git a/web/src/components/series/seriesCounts.ts b/web/src/components/series/seriesCounts.ts index 9e09ffd4..6d2776b2 100644 --- a/web/src/components/series/seriesCounts.ts +++ b/web/src/components/series/seriesCounts.ts @@ -4,6 +4,12 @@ * Inputs come from `series.bookCount` (local count) and `series.metadata` * (`totalVolumeCount`, `totalChapterCount`). Either total may be null/undefined * when the metadata provider didn't expose it. + * + * `localMaxVolume` and `localMaxChapter` are per-series aggregates (Phase 13) + * derived from `book_metadata.volume` / `book_metadata.chapter`. When present, + * the numerator switches from "files on disk" to "highest known unit number" + * so a series with `v01..v14 + v15-c126` correctly displays `14/17 vol` rather + * than `15/17 vol`. */ export interface SeriesCountInputs { @@ -13,6 +19,18 @@ export interface SeriesCountInputs { totalVolumeCount: number | null | undefined; /** Provider's expected chapter total (may be fractional). */ totalChapterCount: number | null | undefined; + /** + * Highest `book_metadata.volume` across the series's books, or null when + * none of the books have `volume` populated. When present, replaces + * `localCount` as the numerator on the volume axis. + */ + localMaxVolume?: number | null | undefined; + /** + * Highest `book_metadata.chapter` across the series's books, or null when + * none of the books have `chapter` populated. When present, replaces the + * unconditional ` ch` chapter part with `/ ch`. + */ + localMaxChapter?: number | null | undefined; } /** @@ -29,38 +47,67 @@ export function formatChapterCount(value: number): string { /** * Build the human-readable count string for the series detail header. * - * Rules (per the metadata-count-split plan, Phase 6): - * - Both totals known: `/ vol · ch` - * - Volume total only: `/ vol` (or ` vol` if local missing) - * - Chapter total only: `/ ch` (the bug-fix case for - * chapter-organized libraries; previously showed `/` and was - * incoherent) + * Rules (per the metadata-count-split plan, Phases 6 + 13): + * - Both totals known: `/ vol · ch` (chapter part gains a + * numerator only when `localMaxChapter` is provided) + * - Volume total only: `/ vol` (or ` vol` if local missing). + * `localMaxVolume` overrides the local-file-count numerator when present. + * - Chapter total only: `/ ch`. `localMaxChapter` overrides the + * local-file-count numerator when present. * - Neither total known: ` books` (legacy display) * - No local + no totals: `null` (caller can hide the line) */ export function formatSeriesCounts(inputs: SeriesCountInputs): string | null { - const { localCount, totalVolumeCount, totalChapterCount } = inputs; + const { + localCount, + totalVolumeCount, + totalChapterCount, + localMaxVolume, + localMaxChapter, + } = inputs; const hasLocal = typeof localCount === "number"; const hasVolume = typeof totalVolumeCount === "number"; const hasChapter = typeof totalChapterCount === "number"; + const hasMaxVolume = typeof localMaxVolume === "number"; + const hasMaxChapter = typeof localMaxChapter === "number"; + + // Choose the volume numerator: prefer the structured max-volume aggregate + // over the file count. Falls back to "no numerator" when neither is known. + const volumeNumerator: number | null = hasMaxVolume + ? (localMaxVolume as number) + : hasLocal + ? (localCount as number) + : null; if (hasVolume && hasChapter) { - const volumePart = hasLocal - ? `${localCount}/${totalVolumeCount} vol` - : `${totalVolumeCount} vol`; - return `${volumePart} · ${formatChapterCount(totalChapterCount)} ch`; + const volumePart = + volumeNumerator !== null + ? `${volumeNumerator}/${totalVolumeCount} vol` + : `${totalVolumeCount} vol`; + const chapterPart = hasMaxChapter + ? `${formatChapterCount(localMaxChapter as number)}/${formatChapterCount(totalChapterCount)} ch` + : `${formatChapterCount(totalChapterCount)} ch`; + return `${volumePart} · ${chapterPart}`; } if (hasVolume) { - return hasLocal - ? `${localCount}/${totalVolumeCount} vol` + return volumeNumerator !== null + ? `${volumeNumerator}/${totalVolumeCount} vol` : `${totalVolumeCount} vol`; } if (hasChapter) { - return hasLocal - ? `${localCount}/${formatChapterCount(totalChapterCount)} ch` + // Chapter-only branch: the bug-fix case for chapter-organized libraries. + // Prefer the structured max-chapter aggregate; otherwise fall back to the + // local file count (legacy) so the line is not entirely numerator-less. + const chapterNumerator: number | null = hasMaxChapter + ? (localMaxChapter as number) + : hasLocal + ? (localCount as number) + : null; + return chapterNumerator !== null + ? `${formatChapterCount(chapterNumerator)}/${formatChapterCount(totalChapterCount)} ch` : `${formatChapterCount(totalChapterCount)} ch`; } diff --git a/web/src/pages/SeriesDetail.tsx b/web/src/pages/SeriesDetail.tsx index d5f383f2..60cb6a47 100644 --- a/web/src/pages/SeriesDetail.tsx +++ b/web/src/pages/SeriesDetail.tsx @@ -770,6 +770,8 @@ export function SeriesDetail() { localCount: series.bookCount ?? null, totalVolumeCount: metadata?.totalVolumeCount ?? null, totalChapterCount: metadata?.totalChapterCount ?? null, + localMaxVolume: series.localMaxVolume ?? null, + localMaxChapter: series.localMaxChapter ?? null, }); return counts ? ( diff --git a/web/src/types/api.generated.ts b/web/src/types/api.generated.ts index 2a7e0b9d..07db3100 100644 --- a/web/src/types/api.generated.ts +++ b/web/src/types/api.generated.ts @@ -9737,6 +9737,20 @@ export interface components { * @example Comics */ libraryName: string; + /** + * Format: float + * @description Highest `book_metadata.chapter` across the books in this series. + * See `SeriesDto::local_max_chapter` for semantics. + * @example 137.5 + */ + localMaxChapter?: number | null; + /** + * Format: int32 + * @description Highest `book_metadata.volume` across the books in this series. + * See `SeriesDto::local_max_volume` for semantics. + * @example 14 + */ + localMaxVolume?: number | null; /** @description Complete series metadata */ metadata: components["schemas"]["SeriesFullMetadata"]; /** @@ -9763,6 +9777,13 @@ export interface components { * @example 2024-01-15T10:30:00Z */ updatedAt: string; + /** + * Format: int64 + * @description Number of books classified as a complete volume (volume set, chapter null). + * See `SeriesDto::volumes_owned` for semantics. + * @example 14 + */ + volumesOwned?: number | null; }; /** @description Request body for batch book thumbnail generation */ GenerateBookThumbnailsRequest: { @@ -12165,6 +12186,27 @@ export interface components { * @example Comics */ libraryName: string; + /** + * Format: float + * @description Highest `book_metadata.chapter` across the books in this series. + * + * `None` when no book in the series has `chapter` populated. When + * non-null and `metadata.totalChapterCount` is also known, the UI renders + * `/ ch`. + * @example 137.5 + */ + localMaxChapter?: number | null; + /** + * Format: int32 + * @description Highest `book_metadata.volume` across the books in this series. + * + * `None` when no book in the series has `volume` populated. When + * non-null and `metadata.totalVolumeCount` is also known, the UI renders + * `/ vol` instead of the legacy + * `/ vol`. + * @example 14 + */ + localMaxVolume?: number | null; /** * @description Filesystem path to the series directory * @example /media/comics/Batman - Year One @@ -12207,6 +12249,17 @@ export interface components { * @example 2024-01-15T10:30:00Z */ updatedAt: string; + /** + * Format: int64 + * @description Number of books in this series classified as a complete volume + * (`volume IS NOT NULL AND chapter IS NULL`). + * + * Distinct from `bookCount`: a chapter inside a volume (`v15 c126`) + * counts as a chapter, not a volume. `None` when no books exist; + * `Some(0)` when books exist but none are complete volumes. + * @example 14 + */ + volumesOwned?: number | null; /** * Format: int32 * @description Release year @@ -14616,6 +14669,27 @@ export interface components { * @example Comics */ libraryName: string; + /** + * Format: float + * @description Highest `book_metadata.chapter` across the books in this series. + * + * `None` when no book in the series has `chapter` populated. When + * non-null and `metadata.totalChapterCount` is also known, the UI renders + * `/ ch`. + * @example 137.5 + */ + localMaxChapter?: number | null; + /** + * Format: int32 + * @description Highest `book_metadata.volume` across the books in this series. + * + * `None` when no book in the series has `volume` populated. When + * non-null and `metadata.totalVolumeCount` is also known, the UI renders + * `/ vol` instead of the legacy + * `/ vol`. + * @example 14 + */ + localMaxVolume?: number | null; /** * @description Filesystem path to the series directory * @example /media/comics/Batman - Year One @@ -14658,6 +14732,17 @@ export interface components { * @example 2024-01-15T10:30:00Z */ updatedAt: string; + /** + * Format: int64 + * @description Number of books in this series classified as a complete volume + * (`volume IS NOT NULL AND chapter IS NULL`). + * + * Distinct from `bookCount`: a chapter inside a volume (`v15 c126`) + * counts as a chapter, not a volume. `None` when no books exist; + * `Some(0)` when books exist but none are complete volumes. + * @example 14 + */ + volumesOwned?: number | null; /** * Format: int32 * @description Release year From fb5692a43640cc4f9a895473f5126f2a9705fc57 Mon Sep 17 00:00:00 2001 From: Sylvain Cau Date: Sat, 2 May 2026 21:43:52 -0700 Subject: [PATCH 15/19] feat(books): expose per-book chapter on the API and surface vol/chap classification in the UI Adds the chapter axis end-to-end on per-book metadata, completing the count-split work down to the individual file. Previously chapter lived only on the entity and series-level aggregates; the per-book DTOs and frontend had no way to read or write it. API: - Add `chapter: Option` and `chapter_lock: bool` to BookMetadataDto, BookFullMetadata, BookMetadataResponse, ReplaceBookMetadataRequest, PatchBookMetadataRequest (PatchValue), BookMetadataLocks, UpdateBookMetadataLocksRequest, and BookMetadataContextDto. - Wire the new field through both write paths in replace_book_metadata and patch_book_metadata with the same auto-lock-on-set rule as `volume`. - update_book_metadata_locks accepts and writes `chapter_lock`. - Preprocessing context exposes `chapter` and `chapter_lock` plus a new `"chapter"` accessor in get_metadata_field for templates and conditions. - Regenerate OpenAPI spec and TypeScript types. UI: - New BookKindBadge component classifies a book by which of (volume, chapter) are populated. Four cases: volume-only -> "Vol N", chapter-only -> "Ch N", both -> "Vol V . Ch C" (single combined badge), neither -> muted "Vol" with explanatory tooltip. Wired into the book detail header next to BookTypeBadge. - BookMetadataEditModal gains a Chapter LockableInput between Volume and Count in the Publication tab, with step="any" so fractional values like 42.5 round-trip cleanly. Independent lock toggle from volume's. - Strategy UI: rename the user-facing label "Book Naming Strategy" to "Book Metadata Strategy" with the description "How book metadata (title, volume, chapter) is extracted from files". Per-strategy descriptions rewritten to call out volume/chapter behavior. The server-side BookStrategy enum is unchanged to avoid migrating stored library configs. Tests added for the new badge component, the chapter round-trip through the edit modal, and the renamed Strategy UI label and description. --- docs/api/openapi.json | 71 +++++++++++++++++++ src/api/routes/v1/dto/book.rs | 40 +++++++++++ src/api/routes/v1/handlers/books.rs | 34 +++++++-- .../metadata/preprocessing/context.rs | 5 ++ tests/api/books.rs | 4 ++ tests/api/metadata_locks.rs | 3 + web/openapi.json | 71 +++++++++++++++++++ .../components/book/BookKindBadge.test.tsx | 41 +++++++++++ web/src/components/book/BookKindBadge.tsx | 71 +++++++++++++++++++ web/src/components/book/index.ts | 1 + .../books/BookMetadataEditModal.test.tsx | 50 +++++++++++++ .../books/BookMetadataEditModal.tsx | 19 +++++ .../components/forms/AddLibraryModal.test.tsx | 6 +- .../forms/StrategySelector.test.tsx | 21 +++++- web/src/components/forms/StrategySelector.tsx | 14 ++-- web/src/pages/BookDetail.tsx | 7 ++ web/src/types/api.generated.ts | 45 ++++++++++++ 17 files changed, 487 insertions(+), 16 deletions(-) create mode 100644 web/src/components/book/BookKindBadge.test.tsx create mode 100644 web/src/components/book/BookKindBadge.tsx diff --git a/docs/api/openapi.json b/docs/api/openapi.json index 6831ef96..629e3061 100644 --- a/docs/api/openapi.json +++ b/docs/api/openapi.json @@ -17887,6 +17887,15 @@ } ] }, + "chapter": { + "type": [ + "number", + "null" + ], + "format": "float", + "description": "Chapter number (may be fractional, e.g. 42.5 for side chapters)", + "example": 42.0 + }, "colorist": { "type": [ "string", @@ -18307,6 +18316,19 @@ "type": "boolean", "description": "Whether book_type is locked" }, + "chapter": { + "type": [ + "number", + "null" + ], + "format": "float", + "description": "Chapter number (may be fractional, e.g. 42.5 for side chapters)", + "example": 42.0 + }, + "chapterLock": { + "type": "boolean", + "description": "Whether chapter is locked" + }, "count": { "type": [ "integer", @@ -18722,6 +18744,15 @@ } ] }, + "chapter": { + "type": [ + "number", + "null" + ], + "format": "float", + "description": "Chapter number (may be fractional, e.g. 42.5 for side chapters)", + "example": 42.0 + }, "colorists": { "type": "array", "items": { @@ -19058,6 +19089,7 @@ "monthLock", "dayLock", "volumeLock", + "chapterLock", "countLock", "isbnsLock", "bookTypeLock", @@ -19095,6 +19127,11 @@ "description": "Whether book_type is locked", "example": false }, + "chapterLock": { + "type": "boolean", + "description": "Whether chapter is locked", + "example": false + }, "coloristLock": { "type": "boolean", "description": "Whether colorist is locked", @@ -19316,6 +19353,15 @@ } ] }, + "chapter": { + "type": [ + "number", + "null" + ], + "format": "float", + "description": "Chapter number (may be fractional, e.g. 42.5 for side chapters)", + "example": 42.0 + }, "colorist": { "type": [ "string", @@ -28440,6 +28486,15 @@ } ] }, + "chapter": { + "type": [ + "number", + "null" + ], + "format": "float", + "description": "Chapter number (may be fractional, e.g. 42.5 for side chapters)", + "example": 42.0 + }, "colorist": { "type": [ "string", @@ -31003,6 +31058,15 @@ } ] }, + "chapter": { + "type": [ + "number", + "null" + ], + "format": "float", + "description": "Chapter number (may be fractional, e.g. 42.5 for side chapters)", + "example": 42.0 + }, "colorist": { "type": [ "string", @@ -35182,6 +35246,13 @@ ], "description": "Whether to lock book_type" }, + "chapterLock": { + "type": [ + "boolean", + "null" + ], + "description": "Whether to lock chapter" + }, "coloristLock": { "type": [ "boolean", diff --git a/src/api/routes/v1/dto/book.rs b/src/api/routes/v1/dto/book.rs index fd4323d4..bcb388b4 100644 --- a/src/api/routes/v1/dto/book.rs +++ b/src/api/routes/v1/dto/book.rs @@ -797,6 +797,11 @@ pub struct BookMetadataDto { #[schema(example = 1)] pub volume: Option, + /// Chapter number (may be fractional, e.g. 42.5 for side chapters) + #[serde(skip_serializing_if = "Option::is_none")] + #[schema(example = 42.0)] + pub chapter: Option, + /// Total count in series #[serde(skip_serializing_if = "Option::is_none")] #[schema(example = 4)] @@ -891,6 +896,10 @@ pub struct ReplaceBookMetadataRequest { #[schema(example = 1)] pub volume: Option, + /// Chapter number (may be fractional, e.g. 42.5 for side chapters) + #[schema(example = 42.0)] + pub chapter: Option, + /// Total count in series #[schema(example = 4)] pub count: Option, @@ -1053,6 +1062,11 @@ pub struct PatchBookMetadataRequest { #[schema(value_type = Option, example = 1, nullable = true)] pub volume: super::patch::PatchValue, + /// Chapter number (may be fractional, e.g. 42.5 for side chapters) + #[serde(default)] + #[schema(value_type = Option, example = 42.0, nullable = true)] + pub chapter: super::patch::PatchValue, + /// Total count in series #[serde(default)] #[schema(value_type = Option, example = 4, nullable = true)] @@ -1211,6 +1225,10 @@ pub struct BookMetadataResponse { #[schema(example = 1)] pub volume: Option, + /// Chapter number (may be fractional, e.g. 42.5 for side chapters) + #[schema(example = 42.0)] + pub chapter: Option, + /// Total count in series #[schema(example = 4)] pub count: Option, @@ -1382,6 +1400,10 @@ pub struct BookMetadataLocks { #[schema(example = false)] pub volume_lock: bool, + /// Whether chapter is locked + #[schema(example = false)] + pub chapter_lock: bool, + /// Whether count is locked #[schema(example = false)] pub count_lock: bool, @@ -1519,6 +1541,9 @@ pub struct UpdateBookMetadataLocksRequest { /// Whether to lock volume pub volume_lock: Option, + /// Whether to lock chapter + pub chapter_lock: Option, + /// Whether to lock count pub count_lock: Option, @@ -1960,6 +1985,11 @@ pub struct BookFullMetadata { #[schema(example = 1)] pub volume: Option, + /// Chapter number (may be fractional, e.g. 42.5 for side chapters) + #[serde(skip_serializing_if = "Option::is_none")] + #[schema(example = 42.0)] + pub chapter: Option, + /// Total count in series #[serde(skip_serializing_if = "Option::is_none")] #[schema(example = 4)] @@ -2290,6 +2320,11 @@ pub struct BookMetadataContextDto { #[schema(example = 1)] pub volume: Option, + /// Chapter number (may be fractional, e.g. 42.5 for side chapters) + #[serde(skip_serializing_if = "Option::is_none")] + #[schema(example = 42.0)] + pub chapter: Option, + /// Total count in series #[serde(skip_serializing_if = "Option::is_none")] #[schema(example = 4)] @@ -2409,6 +2444,9 @@ pub struct BookMetadataContextDto { /// Whether volume is locked #[serde(default)] pub volume_lock: bool, + /// Whether chapter is locked + #[serde(default)] + pub chapter_lock: bool, /// Whether count is locked #[serde(default)] pub count_lock: bool, @@ -2556,6 +2594,7 @@ impl From for Bo month: ctx.metadata.month, day: ctx.metadata.day, volume: ctx.metadata.volume, + chapter: ctx.metadata.chapter, count: ctx.metadata.count, isbns: ctx.metadata.isbns, book_type: ctx.metadata.book_type, @@ -2614,6 +2653,7 @@ impl From for Bo month_lock: ctx.metadata.month_lock, day_lock: ctx.metadata.day_lock, volume_lock: ctx.metadata.volume_lock, + chapter_lock: ctx.metadata.chapter_lock, count_lock: ctx.metadata.count_lock, isbns_lock: ctx.metadata.isbns_lock, book_type_lock: ctx.metadata.book_type_lock, diff --git a/src/api/routes/v1/handlers/books.rs b/src/api/routes/v1/handlers/books.rs index e9c3e53c..f4dd008e 100644 --- a/src/api/routes/v1/handlers/books.rs +++ b/src/api/routes/v1/handlers/books.rs @@ -476,6 +476,7 @@ pub async fn books_to_full_dtos_batched( month: meta.month, day: meta.day, volume: meta.volume, + chapter: meta.chapter, count: meta.count, isbns: meta.isbns.clone(), // Phase 6 fields @@ -528,6 +529,7 @@ pub async fn books_to_full_dtos_batched( month_lock: meta.month_lock, day_lock: meta.day_lock, volume_lock: meta.volume_lock, + chapter_lock: meta.chapter_lock, count_lock: meta.count_lock, isbns_lock: meta.isbns_lock, book_type_lock: meta.book_type_lock, @@ -572,6 +574,7 @@ pub async fn books_to_full_dtos_batched( month: None, day: None, volume: None, + chapter: None, count: None, isbns: None, book_type: None, @@ -617,6 +620,7 @@ pub async fn books_to_full_dtos_batched( month_lock: false, day_lock: false, volume_lock: false, + chapter_lock: false, count_lock: false, isbns_lock: false, book_type_lock: false, @@ -1091,6 +1095,7 @@ pub async fn get_book( month: meta.month, day: meta.day, volume: meta.volume, + chapter: meta.chapter, count: meta.count, isbns: meta.isbns, } @@ -2083,6 +2088,7 @@ pub async fn replace_book_metadata( active.month = Set(request.month); active.day = Set(request.day); active.volume = Set(request.volume); + active.chapter = Set(request.chapter); active.count = Set(request.count); active.isbns = Set(request.isbns.clone()); @@ -2133,6 +2139,9 @@ pub async fn replace_book_metadata( if request.volume.is_some() { active.volume_lock = Set(true); } + if request.chapter.is_some() { + active.chapter_lock = Set(true); + } if request.count.is_some() { active.count_lock = Set(true); } @@ -2178,7 +2187,7 @@ pub async fn replace_book_metadata( month: Set(request.month), day: Set(request.day), volume: Set(request.volume), - chapter: Set(None), + chapter: Set(request.chapter), count: Set(request.count), isbns: Set(request.isbns.clone()), // New Phase 1 fields @@ -2210,7 +2219,7 @@ pub async fn replace_book_metadata( month_lock: Set(request.month.is_some()), day_lock: Set(request.day.is_some()), volume_lock: Set(request.volume.is_some()), - chapter_lock: Set(false), + chapter_lock: Set(request.chapter.is_some()), count_lock: Set(request.count.is_some()), isbns_lock: Set(request.isbns.is_some()), // New Phase 1 lock fields @@ -2295,6 +2304,7 @@ pub async fn replace_book_metadata( month: updated.month, day: updated.day, volume: updated.volume, + chapter: updated.chapter, count: updated.count, isbns: updated.isbns, // New Phase 6 fields @@ -2339,6 +2349,7 @@ pub async fn replace_book_metadata( month_lock: updated.month_lock, day_lock: updated.day_lock, volume_lock: updated.volume_lock, + chapter_lock: updated.chapter_lock, count_lock: updated.count_lock, isbns_lock: updated.isbns_lock, book_type_lock: updated.book_type_lock, @@ -2549,6 +2560,13 @@ pub async fn patch_book_metadata( } has_changes = true; } + if let Some(opt) = request.chapter.into_nested_option() { + active.chapter = Set(opt); + if opt.is_some() { + active.chapter_lock = Set(true); + } + has_changes = true; + } if let Some(opt) = request.count.into_nested_option() { active.count = Set(opt); if opt.is_some() { @@ -2702,6 +2720,7 @@ pub async fn patch_book_metadata( let month_opt = request.month.into_option(); let day_opt = request.day.into_option(); let volume_opt = request.volume.into_option(); + let chapter_opt = request.chapter.into_option(); let count_opt = request.count.into_option(); let isbns_opt = request.isbns.into_option(); // New Phase 6 fields @@ -2767,7 +2786,7 @@ pub async fn patch_book_metadata( month: Set(month_opt), day: Set(day_opt), volume: Set(volume_opt), - chapter: Set(None), + chapter: Set(chapter_opt), count: Set(count_opt), isbns: Set(isbns_opt.clone()), // New Phase 6 fields @@ -2801,7 +2820,7 @@ pub async fn patch_book_metadata( month_lock: Set(month_opt.is_some()), day_lock: Set(day_opt.is_some()), volume_lock: Set(volume_opt.is_some()), - chapter_lock: Set(false), + chapter_lock: Set(chapter_opt.is_some()), count_lock: Set(count_opt.is_some()), isbns_lock: Set(isbns_opt.is_some()), // New Phase 6 lock fields @@ -2888,6 +2907,7 @@ pub async fn patch_book_metadata( month: updated.month, day: updated.day, volume: updated.volume, + chapter: updated.chapter, count: updated.count, isbns: updated.isbns, // New Phase 6 fields @@ -2932,6 +2952,7 @@ pub async fn patch_book_metadata( month_lock: updated.month_lock, day_lock: updated.day_lock, volume_lock: updated.volume_lock, + chapter_lock: updated.chapter_lock, count_lock: updated.count_lock, isbns_lock: updated.isbns_lock, book_type_lock: updated.book_type_lock, @@ -3022,6 +3043,7 @@ pub async fn get_book_metadata_locks( month_lock: metadata.month_lock, day_lock: metadata.day_lock, volume_lock: metadata.volume_lock, + chapter_lock: metadata.chapter_lock, count_lock: metadata.count_lock, isbns_lock: metadata.isbns_lock, // New Phase 6 lock fields @@ -3144,6 +3166,9 @@ pub async fn update_book_metadata_locks( if let Some(v) = request.volume_lock { active.volume_lock = Set(v); } + if let Some(v) = request.chapter_lock { + active.chapter_lock = Set(v); + } if let Some(v) = request.count_lock { active.count_lock = Set(v); } @@ -3234,6 +3259,7 @@ pub async fn update_book_metadata_locks( month_lock: updated.month_lock, day_lock: updated.day_lock, volume_lock: updated.volume_lock, + chapter_lock: updated.chapter_lock, count_lock: updated.count_lock, isbns_lock: updated.isbns_lock, // New Phase 6 lock fields diff --git a/src/services/metadata/preprocessing/context.rs b/src/services/metadata/preprocessing/context.rs index ab3e8175..eb8dd93c 100644 --- a/src/services/metadata/preprocessing/context.rs +++ b/src/services/metadata/preprocessing/context.rs @@ -758,6 +758,7 @@ pub struct BookMetadataContext { pub month: Option, pub day: Option, pub volume: Option, + pub chapter: Option, pub count: Option, pub isbns: Option, pub book_type: Option, @@ -803,6 +804,7 @@ pub struct BookMetadataContext { pub month_lock: bool, pub day_lock: bool, pub volume_lock: bool, + pub chapter_lock: bool, pub count_lock: bool, pub isbns_lock: bool, pub book_type_lock: bool, @@ -897,6 +899,7 @@ impl BookContext { "month" => self.metadata.month.map(|v| FieldValue::Number(v as f64)), "day" => self.metadata.day.map(|v| FieldValue::Number(v as f64)), "volume" => self.metadata.volume.map(|v| FieldValue::Number(v as f64)), + "chapter" => self.metadata.chapter.map(|v| FieldValue::Number(v as f64)), "count" => self.metadata.count.map(|v| FieldValue::Number(v as f64)), "isbns" => self.metadata.isbns.clone().map(FieldValue::String), "bookType" | "book_type" => self.metadata.book_type.clone().map(FieldValue::String), @@ -1089,6 +1092,7 @@ impl BookContextBuilder { month: m.month, day: m.day, volume: m.volume, + chapter: m.chapter, count: m.count, isbns: m.isbns.clone(), book_type: m.book_type.clone(), @@ -1121,6 +1125,7 @@ impl BookContextBuilder { month_lock: m.month_lock, day_lock: m.day_lock, volume_lock: m.volume_lock, + chapter_lock: m.chapter_lock, count_lock: m.count_lock, isbns_lock: m.isbns_lock, book_type_lock: m.book_type_lock, diff --git a/tests/api/books.rs b/tests/api/books.rs index 3c88bfd5..3164abe6 100644 --- a/tests/api/books.rs +++ b/tests/api/books.rs @@ -1550,6 +1550,7 @@ async fn test_replace_book_metadata_creates_record() { month: Some(2), day: None, volume: None, + chapter: None, count: None, isbns: None, book_type: None, @@ -1634,6 +1635,7 @@ async fn test_replace_book_metadata_clears_omitted_fields() { month: None, day: None, volume: None, + chapter: None, count: None, isbns: None, book_type: None, @@ -1681,6 +1683,7 @@ async fn test_replace_book_metadata_clears_omitted_fields() { month: None, day: None, volume: None, + chapter: None, count: None, isbns: None, book_type: None, @@ -1741,6 +1744,7 @@ async fn test_replace_book_metadata_not_found() { month: None, day: None, volume: None, + chapter: None, count: None, isbns: None, book_type: None, diff --git a/tests/api/metadata_locks.rs b/tests/api/metadata_locks.rs index 9835e251..6d7ead33 100644 --- a/tests/api/metadata_locks.rs +++ b/tests/api/metadata_locks.rs @@ -1142,6 +1142,7 @@ async fn test_update_book_metadata_locks_single() { month_lock: None, day_lock: None, volume_lock: None, + chapter_lock: None, count_lock: None, isbns_lock: None, book_type_lock: None, @@ -1218,6 +1219,7 @@ async fn test_update_book_metadata_locks_multiple() { month_lock: None, day_lock: None, volume_lock: None, + chapter_lock: None, count_lock: None, isbns_lock: None, book_type_lock: None, @@ -1313,6 +1315,7 @@ async fn test_update_book_title_sort_lock() { month_lock: None, day_lock: None, volume_lock: None, + chapter_lock: None, count_lock: None, isbns_lock: None, book_type_lock: None, diff --git a/web/openapi.json b/web/openapi.json index 6831ef96..629e3061 100644 --- a/web/openapi.json +++ b/web/openapi.json @@ -17887,6 +17887,15 @@ } ] }, + "chapter": { + "type": [ + "number", + "null" + ], + "format": "float", + "description": "Chapter number (may be fractional, e.g. 42.5 for side chapters)", + "example": 42.0 + }, "colorist": { "type": [ "string", @@ -18307,6 +18316,19 @@ "type": "boolean", "description": "Whether book_type is locked" }, + "chapter": { + "type": [ + "number", + "null" + ], + "format": "float", + "description": "Chapter number (may be fractional, e.g. 42.5 for side chapters)", + "example": 42.0 + }, + "chapterLock": { + "type": "boolean", + "description": "Whether chapter is locked" + }, "count": { "type": [ "integer", @@ -18722,6 +18744,15 @@ } ] }, + "chapter": { + "type": [ + "number", + "null" + ], + "format": "float", + "description": "Chapter number (may be fractional, e.g. 42.5 for side chapters)", + "example": 42.0 + }, "colorists": { "type": "array", "items": { @@ -19058,6 +19089,7 @@ "monthLock", "dayLock", "volumeLock", + "chapterLock", "countLock", "isbnsLock", "bookTypeLock", @@ -19095,6 +19127,11 @@ "description": "Whether book_type is locked", "example": false }, + "chapterLock": { + "type": "boolean", + "description": "Whether chapter is locked", + "example": false + }, "coloristLock": { "type": "boolean", "description": "Whether colorist is locked", @@ -19316,6 +19353,15 @@ } ] }, + "chapter": { + "type": [ + "number", + "null" + ], + "format": "float", + "description": "Chapter number (may be fractional, e.g. 42.5 for side chapters)", + "example": 42.0 + }, "colorist": { "type": [ "string", @@ -28440,6 +28486,15 @@ } ] }, + "chapter": { + "type": [ + "number", + "null" + ], + "format": "float", + "description": "Chapter number (may be fractional, e.g. 42.5 for side chapters)", + "example": 42.0 + }, "colorist": { "type": [ "string", @@ -31003,6 +31058,15 @@ } ] }, + "chapter": { + "type": [ + "number", + "null" + ], + "format": "float", + "description": "Chapter number (may be fractional, e.g. 42.5 for side chapters)", + "example": 42.0 + }, "colorist": { "type": [ "string", @@ -35182,6 +35246,13 @@ ], "description": "Whether to lock book_type" }, + "chapterLock": { + "type": [ + "boolean", + "null" + ], + "description": "Whether to lock chapter" + }, "coloristLock": { "type": [ "boolean", diff --git a/web/src/components/book/BookKindBadge.test.tsx b/web/src/components/book/BookKindBadge.test.tsx new file mode 100644 index 00000000..bbfc93d9 --- /dev/null +++ b/web/src/components/book/BookKindBadge.test.tsx @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vitest"; +import { renderWithProviders, screen } from "@/test/utils"; +import { BookKindBadge } from "./BookKindBadge"; + +describe("BookKindBadge", () => { + it("renders Vol N when only volume is set", () => { + renderWithProviders(); + expect(screen.getByText("Vol 5")).toBeInTheDocument(); + }); + + it("renders Ch N when only chapter is set", () => { + renderWithProviders(); + expect(screen.getByText("Ch 42")).toBeInTheDocument(); + }); + + it("renders Ch N preserving fractional chapters", () => { + renderWithProviders(); + expect(screen.getByText("Ch 42.5")).toBeInTheDocument(); + }); + + it("renders combined Vol V · Ch C when both are set", () => { + renderWithProviders(); + expect(screen.getByText("Vol 15 · Ch 126")).toBeInTheDocument(); + }); + + it("renders muted Vol fallback when neither is set", () => { + renderWithProviders(); + // The badge text is just "Vol" (no number) + expect(screen.getByText("Vol")).toBeInTheDocument(); + // Should be the outline variant (gray, default-to-volume signal) + const badge = screen.getByText("Vol").closest(".mantine-Badge-root"); + expect(badge).toHaveAttribute("data-variant", "outline"); + }); + + it("treats undefined as null for both fields", () => { + renderWithProviders( + , + ); + expect(screen.getByText("Vol")).toBeInTheDocument(); + }); +}); diff --git a/web/src/components/book/BookKindBadge.tsx b/web/src/components/book/BookKindBadge.tsx new file mode 100644 index 00000000..84c5b6c2 --- /dev/null +++ b/web/src/components/book/BookKindBadge.tsx @@ -0,0 +1,71 @@ +import { Badge, type MantineColor, Tooltip } from "@mantine/core"; + +interface BookKindBadgeProps { + volume: number | null | undefined; + chapter: number | null | undefined; + size?: "xs" | "sm" | "md" | "lg" | "xl"; + variant?: "filled" | "light" | "outline" | "dot" | "gradient"; +} + +function formatChapter(chapter: number): string { + // Drop trailing .0 for whole numbers (42.0 → "42") but keep fractions (42.5 → "42.5") + return Number.isInteger(chapter) ? chapter.toString() : chapter.toString(); +} + +/** + * Badge component classifying a book by its (volume, chapter) populated fields. + * + * Four cases: + * - volume set, chapter null → "Vol N" + * - chapter set, volume null → "Ch N" + * - both set → "Vol V · Ch C" + * - neither set → muted "Vol" with hover tooltip explaining the gap + */ +export function BookKindBadge({ + volume, + chapter, + size = "sm", + variant = "light", +}: BookKindBadgeProps) { + const hasVolume = volume !== null && volume !== undefined; + const hasChapter = chapter !== null && chapter !== undefined; + + // Both set: combined badge + if (hasVolume && hasChapter) { + return ( + + Vol {volume} · Ch {formatChapter(chapter)} + + ); + } + + // Volume only + if (hasVolume) { + return ( + + Vol {volume} + + ); + } + + // Chapter only + if (hasChapter) { + return ( + + Ch {formatChapter(chapter)} + + ); + } + + // Neither: muted default-to-volume with explanatory tooltip + return ( + + + Vol + + + ); +} diff --git a/web/src/components/book/index.ts b/web/src/components/book/index.ts index 2ce16f06..b30be670 100644 --- a/web/src/components/book/index.ts +++ b/web/src/components/book/index.ts @@ -3,6 +3,7 @@ export { AwardsCount, AwardsList } from "./AwardsList"; export { BookExternalIds } from "./BookExternalIds"; export { BookFileInfo } from "./BookFileInfo"; export { BookInfoModal } from "./BookInfoModal"; +export { BookKindBadge } from "./BookKindBadge"; export { BookMetadataDisplay } from "./BookMetadataDisplay"; export { BookProgress } from "./BookProgress"; export { BookTypeBadge } from "./BookTypeBadge"; diff --git a/web/src/components/books/BookMetadataEditModal.test.tsx b/web/src/components/books/BookMetadataEditModal.test.tsx index d8b558b6..927213a5 100644 --- a/web/src/components/books/BookMetadataEditModal.test.tsx +++ b/web/src/components/books/BookMetadataEditModal.test.tsx @@ -65,6 +65,8 @@ const mockBookDetail = { imprint: null, genre: "Action", languageIso: "en", + volume: 5, + chapter: 42.5, }, }; @@ -88,6 +90,7 @@ const mockLocks = { monthLock: false, dayLock: false, volumeLock: false, + chapterLock: false, countLock: false, isbnsLock: false, }; @@ -213,4 +216,51 @@ describe("BookMetadataEditModal", () => { expect(booksApi.getDetail).not.toHaveBeenCalled(); expect(booksApi.getMetadataLocks).not.toHaveBeenCalled(); }); + + it("hydrates and round-trips fractional chapter through PATCH", async () => { + renderWithProviders( + , + ); + + // Switch to the publication tab where Volume + Chapter live + await waitFor(() => { + expect( + screen.getByRole("tab", { name: /Publication/i }), + ).toBeInTheDocument(); + }); + screen.getByRole("tab", { name: /Publication/i }).click(); + + // Volume hydrates from mock (5), Chapter hydrates from mock (42.5) + await waitFor(() => { + expect(screen.getByDisplayValue("5")).toBeInTheDocument(); + expect(screen.getByDisplayValue("42.5")).toBeInTheDocument(); + }); + + // Save without further edits; patchMetadata should still receive both values + const saveButton = screen.getByRole("button", { name: /Save Changes/i }); + saveButton.click(); + + await waitFor(() => { + expect(booksApi.patchMetadata).toHaveBeenCalled(); + }); + + const patchCall = (booksApi.patchMetadata as ReturnType).mock + .calls[0]; + expect(patchCall[0]).toBe("test-book-id"); + expect(patchCall[1].volume).toBe(5); + expect(patchCall[1].chapter).toBe(42.5); + + // Locks payload should propagate the chapterLock field independently + await waitFor(() => { + expect(booksApi.updateMetadataLocks).toHaveBeenCalled(); + }); + const locksCall = (booksApi.updateMetadataLocks as ReturnType) + .mock.calls[0]; + expect(locksCall[1]).toHaveProperty("chapterLock"); + expect(locksCall[1]).toHaveProperty("volumeLock"); + }); }); diff --git a/web/src/components/books/BookMetadataEditModal.tsx b/web/src/components/books/BookMetadataEditModal.tsx index e678b4b3..0e65bd41 100644 --- a/web/src/components/books/BookMetadataEditModal.tsx +++ b/web/src/components/books/BookMetadataEditModal.tsx @@ -85,6 +85,7 @@ interface FormState { releaseDay: string; isbn: string; volume: string; + chapter: string; count: string; // Publication edition: string; @@ -132,6 +133,7 @@ interface LocksState { month: boolean; day: boolean; volume: boolean; + chapter: boolean; count: boolean; isbns: boolean; edition: boolean; @@ -182,6 +184,7 @@ function initializeFormState( releaseDay: metadata?.day?.toString() || "", isbn: metadata?.isbns || "", volume: metadata?.volume?.toString() || "", + chapter: metadata?.chapter?.toString() || "", count: metadata?.count?.toString() || "", edition: metadata?.edition || "", originalTitle: metadata?.originalTitle || "", @@ -227,6 +230,7 @@ function initializeLocksState( month: locks?.monthLock || false, day: locks?.dayLock || false, volume: locks?.volumeLock || false, + chapter: locks?.chapterLock || false, count: locks?.countLock || false, isbns: locks?.isbnsLock || false, edition: locks?.editionLock || false, @@ -491,6 +495,9 @@ export function BookMetadataEditModal({ ? Number.parseInt(formState.releaseDay, 10) : null, volume: formState.volume ? Number.parseInt(formState.volume, 10) : null, + chapter: formState.chapter + ? Number.parseFloat(formState.chapter) + : null, count: formState.count ? Number.parseInt(formState.count, 10) : null, isbns: formState.isbn || null, edition: formState.edition || null, @@ -577,6 +584,7 @@ export function BookMetadataEditModal({ monthLock: locksState.month, dayLock: locksState.day, volumeLock: locksState.volume, + chapterLock: locksState.chapter, countLock: locksState.count, isbnsLock: locksState.isbns, editionLock: locksState.edition, @@ -797,6 +805,17 @@ export function BookMetadataEditModal({ placeholder="Volume number" type="number" /> + updateField("chapter", v)} + locked={locksState.chapter} + onLockChange={(v) => updateLock("chapter", v)} + originalValue={originalFormState?.chapter} + placeholder="Chapter (e.g. 42 or 42.5)" + type="number" + step="any" + /> { expect( screen.getByText("Series Detection Strategy"), ).toBeInTheDocument(); - expect(screen.getByText("Book Naming Strategy")).toBeInTheDocument(); + expect(screen.getByText("Book Metadata Strategy")).toBeInTheDocument(); }); }); @@ -1137,7 +1137,7 @@ describe("LibraryModal (Add Mode)", () => { // Change book strategy to smart const bookStrategySelect = screen - .getByText("Book Naming Strategy") + .getByText("Book Metadata Strategy") .closest("div") ?.parentElement?.querySelector('input[type="text"]'); if (bookStrategySelect) { @@ -1218,7 +1218,7 @@ describe("LibraryModal (Add Mode)", () => { expect( screen.getByText("Series Detection Strategy"), ).toBeInTheDocument(); - expect(screen.getByText("Book Naming Strategy")).toBeInTheDocument(); + expect(screen.getByText("Book Metadata Strategy")).toBeInTheDocument(); expect(screen.getByText("Book Number Strategy")).toBeInTheDocument(); }); }); diff --git a/web/src/components/forms/StrategySelector.test.tsx b/web/src/components/forms/StrategySelector.test.tsx index 9ab4436c..c943b4c9 100644 --- a/web/src/components/forms/StrategySelector.test.tsx +++ b/web/src/components/forms/StrategySelector.test.tsx @@ -236,13 +236,30 @@ describe("BookStrategySelector", () => { , ); - expect(screen.getByText("Book Naming Strategy")).toBeInTheDocument(); + expect(screen.getByText("Book Metadata Strategy")).toBeInTheDocument(); // Mantine Select displays the label in the textbox, not the value expect(screen.getByRole("textbox", { hidden: true })).toHaveValue( "Filename (Recommended)", ); }); + it("uses the post-rename label and description (volume/chapter aware copy)", () => { + const onChange = vi.fn(); + + renderWithProviders( + , + ); + + // Renamed label + expect(screen.getByText("Book Metadata Strategy")).toBeInTheDocument(); + // Updated description mentions volume/chapter + expect( + screen.getByText( + "How book metadata (title, volume, chapter) is extracted from files", + ), + ).toBeInTheDocument(); + }); + it("displays description for selected strategy", () => { const onChange = vi.fn(); @@ -384,7 +401,7 @@ describe("BookStrategySelector", () => { // Open the select dropdown - target the strategy select by its label const select = screen.getByRole("textbox", { - name: /book naming strategy/i, + name: /book metadata strategy/i, }); await user.click(select.parentElement!); diff --git a/web/src/components/forms/StrategySelector.tsx b/web/src/components/forms/StrategySelector.tsx index 4648dd00..f3852b0d 100644 --- a/web/src/components/forms/StrategySelector.tsx +++ b/web/src/components/forms/StrategySelector.tsx @@ -93,31 +93,31 @@ export const BOOK_STRATEGIES: BookStrategyData[] = [ value: "filename", label: "Filename (Recommended)", description: - "Use filename without extension. Predictable and Komga-compatible.", + "Parses title, volume, and chapter from the filename. Volume and chapter are detected via `v\\d+` and `c\\d+` patterns. Predictable and Komga-compatible.", }, { value: "metadata_first", label: "Metadata First", description: - "Use ComicInfo/EPUB metadata title if present, fallback to filename.", + "Uses ComicInfo.xml for title, volume, and chapter. Falls back to the filename only for the title when ComicInfo is missing; volume and chapter stay empty.", }, { value: "smart", label: "Smart Detection", description: - "Use metadata only if meaningful (not generic like 'Vol. 3'), otherwise use filename.", + "Reads ComicInfo.xml first, then falls back to filename patterns for any field ComicInfo did not supply (title, volume, chapter).", }, { value: "series_name", label: "Generated Name", description: - "Generate title from series name + position (e.g., 'One Piece v.01').", + "Builds the title from series + volume + chapter context. Volume and chapter come from series detection (e.g., the series-volume-chapter folder strategy).", }, { value: "custom", label: "Custom (Regex)", description: - "User-defined regex pattern to extract title from filename. For unique naming conventions.", + "User-defined regex with named groups `series`, `volume`, `chapter`, `title` to extract metadata from filenames. For unique naming conventions.", hasConfig: true, }, ]; @@ -597,8 +597,8 @@ export function BookStrategySelector({ return (