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/docs/api/openapi.json b/docs/api/openapi.json index 20a86c3f..403d3870 100644 --- a/docs/api/openapi.json +++ b/docs/api/openapi.json @@ -17492,6 +17492,15 @@ "description": "Whether the book has been analyzed (page dimensions available)", "example": true }, + "chapter": { + "type": [ + "number", + "null" + ], + "format": "float", + "description": "Chapter number from book metadata (may be fractional)", + "example": 42.5 + }, "createdAt": { "type": "string", "format": "date-time", @@ -17611,6 +17620,15 @@ "format": "date-time", "description": "When the book was last updated", "example": "2024-01-15T10:30:00Z" + }, + "volume": { + "type": [ + "integer", + "null" + ], + "format": "int32", + "description": "Volume number from book metadata", + "example": 1 } } }, @@ -17887,6 +17905,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 +18334,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 +18762,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 +19107,7 @@ "monthLock", "dayLock", "volumeLock", + "chapterLock", "countLock", "isbnsLock", "bookTypeLock", @@ -19095,6 +19145,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 +19371,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", @@ -20397,13 +20461,21 @@ ], "description": "Series status (ongoing, ended, hiatus, abandoned, unknown)" }, - "totalBookCount": { + "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 book count (for ongoing series)" + "description": "Expected total volume count (for volume-organized series)." }, "year": { "type": [ @@ -23302,13 +23374,22 @@ "description": "Custom sort name for ordering", "example": "Batman Year One" }, - "totalBookCount": { + "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 book count (for ongoing series)", + "description": "Expected total volume count (for volume-organized series).", "example": 4 }, "updatedAt": { @@ -23418,6 +23499,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" @@ -23459,6 +23558,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 } } }, @@ -25423,11 +25531,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." } } }, @@ -26304,18 +26412,31 @@ "type": "boolean", "description": "Whether title_sort is locked" }, - "totalBookCount": { + "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 book count", + "description": "Expected total volume count", "example": 110 }, - "totalBookCountLock": { + "totalVolumeCountLock": { "type": "boolean", - "description": "Whether total_book_count is locked" + "description": "Whether total_volume_count is locked" }, "year": { "type": [ @@ -26377,7 +26498,8 @@ "language", "readingDirection", "year", - "totalBookCount", + "totalVolumeCount", + "totalChapterCount", "genres", "tags", "customMetadata", @@ -26461,9 +26583,14 @@ "description": "Whether the title_sort field is locked", "example": false }, - "totalBookCount": { + "totalChapterCount": { + "type": "boolean", + "description": "Whether the total_chapter_count field is locked", + "example": false + }, + "totalVolumeCount": { "type": "boolean", - "description": "Whether the total_book_count field is locked", + "description": "Whether the total_volume_count field is locked", "example": false }, "year": { @@ -27335,6 +27462,15 @@ "description": "Whether the book has been analyzed (page dimensions available)", "example": true }, + "chapter": { + "type": [ + "number", + "null" + ], + "format": "float", + "description": "Chapter number from book metadata (may be fractional)", + "example": 42.5 + }, "createdAt": { "type": "string", "format": "date-time", @@ -27454,6 +27590,15 @@ "format": "date-time", "description": "When the book was last updated", "example": "2024-01-15T10:30:00Z" + }, + "volume": { + "type": [ + "integer", + "null" + ], + "format": "int32", + "description": "Volume number from book metadata", + "example": 1 } } }, @@ -27847,6 +27992,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", @@ -27907,6 +28070,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", @@ -28350,6 +28522,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", @@ -28710,13 +28891,22 @@ "description": "Custom sort name for ordering", "example": "Batman Year One" }, - "totalBookCount": { + "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 book count (for ongoing series)", + "description": "Expected total volume count (for volume-organized series)", "example": 4 }, "year": { @@ -30659,13 +30849,21 @@ "type": "string", "description": "Title of the recommended series/book" }, - "totalBookCount": { + "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 books/volumes in the series" + "description": "Total expected number of volumes in the series." } } }, @@ -30896,6 +31094,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", @@ -31238,13 +31445,22 @@ "description": "Custom sort name for ordering (e.g., \"Batman Year One\" instead of \"The Batman Year One\")", "example": "Batman Year One" }, - "totalBookCount": { + "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 book count (for ongoing series)", + "description": "Expected total volume count (for volume-organized series).", "example": 4 }, "year": { @@ -31646,6 +31862,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": { @@ -31913,7 +32136,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" ], @@ -32145,6 +32368,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", @@ -32205,6 +32446,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", @@ -32513,13 +32763,22 @@ "description": "Custom sort name for ordering", "example": "Batman Year One" }, - "totalBookCount": { + "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 book count (for ongoing series)", + "description": "Expected total volume count (for volume-organized series).", "example": 4 }, "updatedAt": { @@ -32682,13 +32941,22 @@ "description": "Custom sort name for ordering", "example": "Batman Year One" }, - "totalBookCount": { + "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 book count (for ongoing series)", + "description": "Expected total volume count (for volume-organized series).", "example": 4 }, "updatedAt": { @@ -35014,6 +35282,13 @@ ], "description": "Whether to lock book_type" }, + "chapterLock": { + "type": [ + "boolean", + "null" + ], + "description": "Whether to lock chapter" + }, "coloristLock": { "type": [ "boolean", @@ -35493,12 +35768,20 @@ "description": "Whether to lock the title_sort field", "example": false }, - "totalBookCount": { + "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_book_count field", + "description": "Whether to lock the total_volume_count field", "example": false }, "year": { 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 6febfc43..47f5cdfc 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; } ``` @@ -397,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/book-metadata.md b/docs/docs/book-metadata.md index 2b694d4c..cb618528 100644 --- a/docs/docs/book-metadata.md +++ b/docs/docs/book-metadata.md @@ -34,6 +34,60 @@ Book type can be set in several ways: Like other metadata fields, book type supports locking. When locked, metadata plugins cannot overwrite the value during automatic metadata fetching. Lock a field by toggling the lock icon next to it in the edit modal. +## Volume and Chapter Numbers + +Each book carries an optional **volume** (integer) and **chapter** (decimal) number. Codex uses these to classify each book and to compute per-series aggregates (`local_max_volume`, `local_max_chapter`, `volumes_owned`) that drive the series count display and "behind by N" indicators. + +How a book is classified depends on which fields are populated: + +| `volume` | `chapter` | Book detail badge | Meaning | +|----------|-----------|-------------------|---------| +| set | unset | `Vol N` (blue) | A bound volume. | +| unset | set | `Ch N` (grape) | A loose chapter. | +| set | set | `Vol V · Ch C` (blue) | A specific chapter inside a known volume. | +| unset | unset | `Vol` (gray, muted) | Unclassified; defaults to volume. Click to edit. | + +### Filename Convention + +When the library uses the **Filename** or **Smart** [book strategy](./scanning-strategies/book-strategies), Codex extracts `volume` and `chapter` from the filename using these canonical patterns: + +| Pattern | Example | Result | +|---------|---------|--------| +| `vN`, `vol.N`, `volume N` | `One Piece v01.cbz` | `volume = 1` | +| `cN`, `ch.N`, `chapter N` | `One Piece c042.cbz` | `chapter = 42` | +| Both, separated by anything non-alphanumeric | `One Piece v15 - c126 (2023).cbz` | `volume = 15`, `chapter = 126` | +| Fractional chapters | `One Piece c042.5.cbz` | `chapter = 42.5` | + +Rules and edge cases: + +- The prefix (`v` / `vol` / `volume`, `c` / `ch` / `chapter`) is required and **case-insensitive**. Bare numbers without a prefix are not parsed into the volume or chapter axis. They may still be parsed as the **book number** for sort order; see [book strategies](./scanning-strategies/book-strategies). +- The prefix must sit on a **non-alphanumeric left boundary** (start of name, space, underscore, dash, `[`, or `(`). This prevents uploader tags like `[GroupName]` and words containing `c` mid-string from triggering false matches. +- **Fractional volumes are rejected.** `Series v01.5.cbz` produces `volume = NULL` because the volume column is an integer. Use the chapter axis for fractional values. +- **First match wins per axis.** If a filename contains multiple volume markers (e.g. accidental duplication), the first one is used. +- A bare year in parentheses or after a dash (e.g. `v01 - 2024 (Digital).cbz`) is **not** mistaken for a chapter number. Only `c`-prefixed numbers are read as chapters. + +:::tip Recommended naming +For volume-organized libraries, name files `Series Name v01.cbz`, `Series Name v02.cbz`, etc. For chapter-organized libraries, name files `Series Name c001.cbz`. For mixed libraries with bound volumes plus loose chapters, mix both forms or use `v15 - c126.cbz` for chapters known to belong to a specific volume. +::: + +### ComicInfo.xml Override + +When a CBZ contains a `ComicInfo.xml`, the embedded `` and `` tags take precedence over filename parsing if the [book strategy](./scanning-strategies/book-strategies) is **Smart** or **Metadata First**. Specifically: + +- **Filename strategy**: ComicInfo is ignored. Filename is the only source. +- **Metadata First strategy**: ComicInfo is the only source. Filename is ignored. +- **Smart strategy**: ComicInfo first, filename fallback if ComicInfo doesn't carry the field. + +This means a series that drops to using filename naming after dropping ComicInfo (or vice versa) keeps the same volume/chapter values across rescans on the **Smart** strategy. + +### Custom Regex (Non-Canonical Filenames) + +If your filenames don't match the canonical patterns above, configure a [custom book strategy](./scanning-strategies/book-strategies#custom) with a regex that names `volume` and `chapter` capture groups. See [Configuration Examples](./scanning-strategies/examples) for worked examples covering scanlation-bracketed releases, `SxxExx`-style episode numbering, and other non-canonical layouts. + +### Locking Volume and Chapter + +Like every other metadata field, volume and chapter have **independent lock toggles**. Setting a value through the manual edit modal locks that field automatically, so a future rescan won't clobber it. You can lock or unlock either field independently in the edit modal's Publication tab. + ## Extended Metadata Fields Books support additional metadata fields beyond the basic title, summary, and publisher: @@ -56,6 +110,8 @@ Books support additional metadata fields beyond the basic title, summary, and pu |-------|-------------| | **Subjects** | Subject categories (e.g., "Science Fiction", "Space Exploration") | | **Book Type** | Publication type classification (see above) | +| **Volume** | The volume this book belongs to (integer). Optional. | +| **Chapter** | The chapter this book contains (decimal, e.g. `42.5`). Optional. | ### Credits Fields 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/scanning-strategies/book-strategies.md b/docs/docs/scanning-strategies/book-strategies.md index 83e60df3..49328d36 100644 --- a/docs/docs/scanning-strategies/book-strategies.md +++ b/docs/docs/scanning-strategies/book-strategies.md @@ -8,7 +8,7 @@ Book strategies determine how Codex names individual books and extracts volume/c ## Filename (Default) -**Rule:** Book title = filename without extension +**Rule:** Book title = filename without extension. **Volume** and **chapter** are extracted from canonical filename markers (`v01`, `c042`, etc.); ComicInfo is ignored. This is the Komga-compatible default behavior. @@ -17,9 +17,17 @@ File: Batman #003.cbz Title: "Batman #003" File: One Piece v01.cbz -Title: "One Piece v01" +Title: "One Piece v01" → volume: 1 + +File: One Piece c042.cbz +Title: "One Piece c042" → chapter: 42 + +File: One Piece v15 - c126 (2023).cbz +Title: "One Piece v15 - c126 (2023)" → volume: 15, chapter: 126 ``` +See [Volume and Chapter Numbers](../book-metadata#volume-and-chapter-numbers) for the full filename-pattern reference. + **Pros:** - Predictable: what you see on disk = what you see in UI - Komga-compatible @@ -40,7 +48,7 @@ Title: "One Piece v01" ## Metadata First -**Rule:** Use ComicInfo `` if present, fallback to filename +**Rule:** Use ComicInfo `<Title>` if present, fallback to filename. For **volume** and **chapter**, ComicInfo is the only source; the filename is never parsed for these fields. ``` File: +Anima #03.cbz @@ -71,7 +79,7 @@ Title: "Batman #001" ## Smart -**Rule:** Use metadata only if it's meaningful, otherwise use filename +**Rule:** Use metadata only if it's meaningful, otherwise use filename. For **volume** and **chapter**, ComicInfo is consulted first; if it doesn't carry the field, the canonical filename markers are used as fallback. ``` File: +Anima #03.cbz @@ -190,11 +198,13 @@ Series [V01] [C003].cbz → volume: 1, chapter: 3, title: "Series v.01 c.003" | Group | Purpose | Example | |-------|---------|---------| -| `(?P<volume>...)` | Extract volume number | `v(?P<volume>\d+)` matches "v12" → 12 | -| `(?P<chapter>...)` | Extract chapter number | `c(?P<chapter>\d+)` matches "c145" → 145 | +| `(?P<volume>...)` | Extract volume number (integer; persisted as the book's `volume` field) | `v(?P<volume>\d+)` matches "v12" → 12 | +| `(?P<chapter>...)` | Extract chapter number (decimal; persisted as the book's `chapter` field) | `c(?P<chapter>\d+(?:\.\d+)?)` matches "c145.5" → 145.5 | | `(?P<title>...)` | Extract title portion | `- (?P<title>.+)$` matches "- Romance Dawn" | | `(?P<series>...)` | Extract series (for template) | `^(?P<series>.+?)_` | +The captured `volume` and `chapter` values populate the per-book metadata fields and feed the series-level `local_max_volume` / `local_max_chapter` aggregates and "behind by N" comparisons. See [Volume and Chapter Numbers](../book-metadata#volume-and-chapter-numbers) for the canonical patterns the default `Filename` and `Smart` strategies recognize without custom regex. + **Common patterns:** | Naming Convention | Pattern | diff --git a/docs/docs/scanning-strategies/examples.md b/docs/docs/scanning-strategies/examples.md index 44776ccf..57799e57 100644 --- a/docs/docs/scanning-strategies/examples.md +++ b/docs/docs/scanning-strategies/examples.md @@ -216,7 +216,7 @@ Group all books by the same author into a series. ## Custom: Scanlation Group Format -Files with scanlation group tags. +Files with scanlation group tags. The `(?P<volume>\d+)` and `(?P<chapter>\d+)` named groups populate the per-book volume/chapter fields, which feed the series count display and "behind by N" indicators. ``` /library/ @@ -233,18 +233,20 @@ Files with scanlation group tags. }, "book_strategy": "custom", "book_config": { - "pattern": "\\] (?P<series>.+?) v(?P<volume>\\d+) c(?P<chapter>\\d+)", + "pattern": "\\] (?P<series>.+?) v(?P<volume>\\d+) c(?P<chapter>\\d+(?:\\.\\d+)?)", "title_template": "{series} v.{volume} c.{chapter}", "fallback": "filename" } } ``` +The `(?:\.\d+)?` inside the chapter group makes fractional chapter numbers (`c042.5`) work for special chapters and side stories. Drop it if your library never uses fractional chapters. + --- ## Custom: TV-style Episode Numbering -Files with SxxExx or seasonXepisode format. +Files with SxxExx or seasonXepisode format. Each season maps to a volume; each episode maps to a chapter. ``` /library/ @@ -270,6 +272,61 @@ Files with SxxExx or seasonXepisode format. --- +## Custom: Chapter-Only Library With Range Filenames + +Some scanlation packs distribute chapter ranges as a single file (`c001-005.cbz`). Capture the **first** chapter of the range so the series's `local_max_chapter` aggregate stays correct. + +``` +/library/ + └── Series Name/ + ├── Series Name - c001-005.cbz + ├── Series Name - c006-010.cbz + └── Series Name - c011.cbz +``` + +```json +{ + "series_strategy": "series_volume", + "book_strategy": "custom", + "book_config": { + "pattern": "^(?P<series>.+?) - c(?P<chapter>\\d+(?:\\.\\d+)?)(?:-\\d+)?$", + "title_template": "{series} c.{chapter}", + "fallback": "filename" + } +} +``` + +The `(?:-\d+)?` in the pattern matches and discards the optional `-005` end-of-range portion so each book is classified by its starting chapter. No `volume` group means books are classified as chapter-only. + +--- + +## Custom: Volume-Of-Series + Issue Number + +Western-comic-style filenames where each issue is also a volume's worth of standalone content (`Series Vol.1 #5`). + +``` +/library/ + ├── Saga Vol.1 #001.cbz + ├── Saga Vol.1 #002.cbz + └── Saga Vol.2 #001.cbz +``` + +```json +{ + "series_strategy": "series_volume", + "book_strategy": "custom", + "book_config": { + "pattern": "^(?P<series>.+?) Vol\\.(?P<volume>\\d+) #(?P<chapter>\\d+(?:\\.\\d+)?)", + "title_template": "{series} v.{volume} #{chapter}", + "fallback": "filename" + } +} +``` + +This populates **both** `volume` and `chapter` per book, so the book detail page renders the combined `Vol V · Ch C` badge and the series detail header shows both totals when known. + +--- + ## Creating via API ### Basic Creation diff --git a/docs/docs/series-metadata.md b/docs/docs/series-metadata.md new file mode 100644 index 00000000..393e9fbd --- /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) 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", 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 96ec6de8..36cf07dd 100644 --- a/migration/src/lib.rs +++ b/migration/src/lib.rs @@ -135,6 +135,15 @@ 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; +// 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; +// Backfill volume/chapter from filename for already-scanned books (Phase 12) +mod m20260503_000070_backfill_book_volume_chapter; + pub struct Migrator; #[async_trait::async_trait] @@ -241,6 +250,14 @@ 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), + // 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), + // Backfill book_metadata.volume / .chapter from filename (Phase 12) + Box::new(m20260503_000070_backfill_book_volume_chapter::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/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/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<i32>` and +//! `volume_lock`. This migration adds the sibling `chapter Option<f32>` 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/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..0b2f534a --- /dev/null +++ b/migration/src/m20260503_000070_backfill_book_volume_chapter.rs @@ -0,0 +1,185 @@ +//! 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}; + +use crate::m20260103_000014_create_book_metadata::BookMetadata; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +const BATCH_SIZE: u64 = 1000; + +#[derive(Debug, FromQueryResult)] +struct Row { + book_id: uuid::Uuid, + file_name: String, + volume: Option<i32>, + chapter: Option<f32>, +} + +#[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 via sea-query so placeholders render correctly + // for the active backend (`?` for SQLite, `$1..$N` for Postgres). + let mut update = Query::update(); + update.table(BookMetadata::Table); + if let Some(v) = new_volume { + update.value(BookMetadata::Volume, v); + } + if let Some(c) = new_chapter { + update.value(Alias::new("chapter"), c); + } + update.and_where(Expr::col(BookMetadata::BookId).eq(row.book_id)); + + txn.execute(backend.build(&update)).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<i32> { + 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::<i32>().ok() +} + +fn extract_chapter(file_name: &str) -> Option<f32> { + let name = name_without_ext(file_name); + let captures = CHAPTER_PATTERN.captures(name)?; + captures.get(1)?.as_str().parse::<f32>().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/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/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..ed3a1841 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", () => { @@ -340,6 +402,98 @@ 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); + }); + + 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(); + }); + + 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..ca5dd46a 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 <br> to newlines */ @@ -172,8 +194,9 @@ 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, + format: series.type ?? undefined, }, }; } @@ -328,6 +351,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 +364,8 @@ export function mapSeriesMetadata(series: MbSeries): PluginSeriesMetadata { year: series.year ?? undefined, // Extended metadata publisher, - totalBookCount: series.final_volume ? Number.parseInt(series.final_volume, 10) : undefined, + 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..42241f8b 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,16 +262,42 @@ 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].totalBookCount).toBe(27); + expect(results[0].totalVolumeCount).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].totalBookCount).toBeUndefined(); + expect(results[0].totalVolumeCount).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); + }); + + 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(); }); it("includes rating from AniList averageScore", () => { diff --git a/plugins/recommendations-anilist/src/index.ts b/plugins/recommendations-anilist/src/index.ts index 15a7af62..5cb64b07 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,8 @@ export function convertRecommendations( format: media.format ?? undefined, countryOfOrigin: media.countryOfOrigin ?? undefined, startYear: media.startDate?.year ?? undefined, - totalBookCount, + 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..706015d2 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; } // ============================================================================= @@ -105,8 +117,17 @@ export interface PluginSeriesMetadata { year?: number; // Extended metadata - /** Expected total number of books in the series */ - 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..565cbf62 100644 --- a/plugins/sdk-typescript/src/types/recommendations.ts +++ b/plugins/sdk-typescript/src/types/recommendations.ts @@ -39,8 +39,10 @@ export interface UserLibraryEntry { genres: string[]; /** Tags */ tags: string[]; - /** Total number of books in the series */ - 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 +111,10 @@ export interface Recommendation { countryOfOrigin?: string; /** Year the series started */ startYear?: number; - /** Total expected number of books/volumes in the series */ - 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 */ 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<i32>, - /// 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<i32>, + /// 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<String>, @@ -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 578d1d4b..784bf6cf 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_volume_count: m.total_volume_count, + total_volume_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/book.rs b/src/api/routes/v1/dto/book.rs index fd4323d4..579e4722 100644 --- a/src/api/routes/v1/dto/book.rs +++ b/src/api/routes/v1/dto/book.rs @@ -598,6 +598,16 @@ pub struct BookDto { #[serde(skip_serializing_if = "Option::is_none")] #[schema(example = "ltr")] pub reading_direction: Option<String>, + + /// Volume number from book metadata + #[serde(skip_serializing_if = "Option::is_none")] + #[schema(example = 1)] + pub volume: Option<i32>, + + /// Chapter number from book metadata (may be fractional) + #[serde(skip_serializing_if = "Option::is_none")] + #[schema(example = 42.5)] + pub chapter: Option<f32>, } /// Book list response @@ -797,6 +807,11 @@ pub struct BookMetadataDto { #[schema(example = 1)] pub volume: Option<i32>, + /// 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<f32>, + /// Total count in series #[serde(skip_serializing_if = "Option::is_none")] #[schema(example = 4)] @@ -891,6 +906,10 @@ pub struct ReplaceBookMetadataRequest { #[schema(example = 1)] pub volume: Option<i32>, + /// Chapter number (may be fractional, e.g. 42.5 for side chapters) + #[schema(example = 42.0)] + pub chapter: Option<f32>, + /// Total count in series #[schema(example = 4)] pub count: Option<i32>, @@ -1053,6 +1072,11 @@ pub struct PatchBookMetadataRequest { #[schema(value_type = Option<i32>, example = 1, nullable = true)] pub volume: super::patch::PatchValue<i32>, + /// Chapter number (may be fractional, e.g. 42.5 for side chapters) + #[serde(default)] + #[schema(value_type = Option<f32>, example = 42.0, nullable = true)] + pub chapter: super::patch::PatchValue<f32>, + /// Total count in series #[serde(default)] #[schema(value_type = Option<i32>, example = 4, nullable = true)] @@ -1211,6 +1235,10 @@ pub struct BookMetadataResponse { #[schema(example = 1)] pub volume: Option<i32>, + /// Chapter number (may be fractional, e.g. 42.5 for side chapters) + #[schema(example = 42.0)] + pub chapter: Option<f32>, + /// Total count in series #[schema(example = 4)] pub count: Option<i32>, @@ -1382,6 +1410,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 +1551,9 @@ pub struct UpdateBookMetadataLocksRequest { /// Whether to lock volume pub volume_lock: Option<bool>, + /// Whether to lock chapter + pub chapter_lock: Option<bool>, + /// Whether to lock count pub count_lock: Option<bool>, @@ -1960,6 +1995,11 @@ pub struct BookFullMetadata { #[schema(example = 1)] pub volume: Option<i32>, + /// 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<f32>, + /// Total count in series #[serde(skip_serializing_if = "Option::is_none")] #[schema(example = 4)] @@ -2290,6 +2330,11 @@ pub struct BookMetadataContextDto { #[schema(example = 1)] pub volume: Option<i32>, + /// 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<f32>, + /// Total count in series #[serde(skip_serializing_if = "Option::is_none")] #[schema(example = 4)] @@ -2409,6 +2454,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 +2604,7 @@ impl From<crate::services::metadata::preprocessing::context::BookContext> 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 +2663,7 @@ impl From<crate::services::metadata::preprocessing::context::BookContext> 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/dto/bulk_metadata.rs b/src/api/routes/v1/dto/bulk_metadata.rs index f2941a31..385449a5 100644 --- a/src/api/routes/v1/dto/bulk_metadata.rs +++ b/src/api/routes/v1/dto/bulk_metadata.rs @@ -81,10 +81,15 @@ pub struct BulkPatchSeriesMetadataRequest { #[schema(value_type = Option<i32>, nullable = true)] pub year: super::patch::PatchValue<i32>, - /// Expected total book count (for ongoing series) + /// Expected total volume count (for volume-organized series). #[serde(default)] #[schema(value_type = Option<i32>, nullable = true)] - pub total_book_count: super::patch::PatchValue<i32>, + pub total_volume_count: super::patch::PatchValue<i32>, + + /// Expected total chapter count (for chapter-organized series). May be fractional. + #[serde(default)] + #[schema(value_type = Option<f32>, nullable = true)] + pub total_chapter_count: super::patch::PatchValue<f32>, /// Custom JSON metadata (uses RFC 7386 JSON Merge Patch semantics) #[serde(default)] 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/plugins.rs b/src/api/routes/v1/dto/plugins.rs index 7ee26434..367b5f6c 100644 --- a/src/api/routes/v1/dto/plugins.rs +++ b/src/api/routes/v1/dto/plugins.rs @@ -940,7 +940,8 @@ 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 "metadata:write:book_type", "metadata:write:subtitle", @@ -1150,6 +1151,14 @@ pub struct SearchResultPreviewDto { /// Author names (book search results) #[serde(default, skip_serializing_if = "Vec::is_empty")] pub authors: Vec<String>, + + /// 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<String>, } /// Response containing search results from a plugin @@ -1433,6 +1442,7 @@ impl From<crate::services::plugin::protocol::SearchResultPreview> for SearchResu description: p.description, book_count: p.book_count, authors: p.authors, + format: p.format, } } } diff --git a/src/api/routes/v1/dto/recommendations.rs b/src/api/routes/v1/dto/recommendations.rs index c2842fc5..c68463f5 100644 --- a/src/api/routes/v1/dto/recommendations.rs +++ b/src/api/routes/v1/dto/recommendations.rs @@ -69,9 +69,12 @@ pub struct RecommendationDto { /// Year the series started #[serde(skip_serializing_if = "Option::is_none")] pub start_year: Option<i32>, - /// Total expected number of books/volumes in the series + /// Total expected number of volumes in the series. #[serde(skip_serializing_if = "Option::is_none")] - pub total_book_count: Option<i32>, + pub total_volume_count: Option<i32>, + /// Total expected number of chapters in the series. May be fractional. + #[serde(skip_serializing_if = "Option::is_none")] + pub total_chapter_count: Option<f32>, /// Average user rating on the source service (0-100 scale) #[serde(skip_serializing_if = "Option::is_none")] pub rating: Option<i32>, @@ -155,7 +158,8 @@ 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, popularity: None, }; @@ -168,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/dto/series.rs b/src/api/routes/v1/dto/series.rs index eb7bba8c..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 + /// `<localMaxVolume>/<totalVolumeCount> vol` instead of the legacy + /// `<bookCount>/<totalVolumeCount> vol`. + #[serde(skip_serializing_if = "Option::is_none")] + #[schema(example = 14)] + pub local_max_volume: Option<i32>, + + /// 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 + /// `<localMaxChapter>/<totalChapterCount> ch`. + #[serde(skip_serializing_if = "Option::is_none")] + #[schema(example = 137.5)] + pub local_max_chapter: Option<f32>, + + /// 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<i64>, + /// Filesystem path to the series directory #[serde(skip_serializing_if = "Option::is_none")] #[schema(example = "/media/comics/Batman - Year One")] @@ -314,9 +343,13 @@ pub struct ReplaceSeriesMetadataRequest { #[schema(example = 1987)] pub year: Option<i32>, - /// Expected total book count (for ongoing series) + /// Expected total volume count (for volume-organized series). #[schema(example = 4)] - pub total_book_count: Option<i32>, + pub total_volume_count: Option<i32>, + + /// Expected total chapter count (for chapter-organized series). May be fractional. + #[schema(example = 109.5)] + pub total_chapter_count: Option<f32>, /// Custom JSON metadata for extensions #[schema(value_type = Option<Object>, example = json!({"myField": "value", "nested": {"key": "data"}}))] @@ -383,10 +416,15 @@ pub struct PatchSeriesMetadataRequest { #[schema(value_type = Option<i32>, example = 1987, nullable = true)] pub year: super::patch::PatchValue<i32>, - /// Expected total book count (for ongoing series) + /// Expected total volume count (for volume-organized series) #[serde(default)] #[schema(value_type = Option<i32>, example = 4, nullable = true)] - pub total_book_count: super::patch::PatchValue<i32>, + pub total_volume_count: super::patch::PatchValue<i32>, + + /// Expected total chapter count (for chapter-organized series). May be fractional. + #[serde(default)] + #[schema(value_type = Option<f32>, example = 109.5, nullable = true)] + pub total_chapter_count: super::patch::PatchValue<f32>, /// Custom JSON metadata for extensions #[serde(default)] @@ -447,9 +485,13 @@ pub struct SeriesMetadataResponse { #[schema(example = 1987)] pub year: Option<i32>, - /// Expected total book count (for ongoing series) + /// Expected total volume count (for volume-organized series). #[schema(example = 4)] - pub total_book_count: Option<i32>, + pub total_volume_count: Option<i32>, + + /// Expected total chapter count (for chapter-organized series). May be fractional. + #[schema(example = 109.5)] + pub total_chapter_count: Option<f32>, /// Custom JSON metadata for extensions #[schema(value_type = Option<Object>, example = json!({"myField": "value"}))] @@ -974,9 +1016,13 @@ pub struct MetadataLocks { #[schema(example = false)] pub year: bool, - /// Whether the total_book_count field is locked + /// 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_book_count: bool, + pub total_chapter_count: bool, /// Whether the genres are locked #[schema(example = false)] @@ -1053,9 +1099,13 @@ pub struct FullSeriesMetadataResponse { #[schema(example = 1987)] pub year: Option<i32>, - /// Expected total book count (for ongoing series) + /// Expected total volume count (for volume-organized series). #[schema(example = 4)] - pub total_book_count: Option<i32>, + pub total_volume_count: Option<i32>, + + /// Expected total chapter count (for chapter-organized series). May be fractional. + #[schema(example = 109.5)] + pub total_chapter_count: Option<f32>, /// Custom JSON metadata #[schema(value_type = Option<Object>, example = json!({"myField": "value"}))] @@ -1146,10 +1196,15 @@ pub struct SeriesFullMetadata { #[schema(example = 1987)] pub year: Option<i32>, - /// Expected total book count (for ongoing series) + /// Expected total volume count (for volume-organized series). #[serde(skip_serializing_if = "Option::is_none")] #[schema(example = 4)] - pub total_book_count: Option<i32>, + pub total_volume_count: Option<i32>, + + /// 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<f32>, /// Custom JSON metadata #[serde(skip_serializing_if = "Option::is_none")] @@ -1192,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<i32>, + + /// 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<f32>, + + /// 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<i64>, + /// Number of unread books in this series (user-specific) #[serde(skip_serializing_if = "Option::is_none")] #[schema(example = 2)] @@ -1296,10 +1369,15 @@ pub struct UpdateMetadataLocksRequest { #[schema(example = false)] pub year: Option<bool>, - /// Whether to lock the total_book_count field + /// Whether to lock the total_volume_count field #[serde(skip_serializing_if = "Option::is_none")] #[schema(example = false)] - pub total_book_count: Option<bool>, + pub total_volume_count: Option<bool>, + + /// Whether to lock the total_chapter_count field + #[serde(skip_serializing_if = "Option::is_none")] + #[schema(example = false)] + pub total_chapter_count: Option<bool>, /// Whether to lock the genres #[serde(skip_serializing_if = "Option::is_none")] @@ -1673,10 +1751,15 @@ pub struct MetadataContextDto { #[schema(example = 1997)] pub year: Option<i32>, - /// Expected total book count + /// Expected total volume count #[serde(skip_serializing_if = "Option::is_none")] #[schema(example = 110)] - pub total_book_count: Option<i32>, + pub total_volume_count: Option<i32>, + + /// Expected total chapter count (may be fractional) + #[serde(skip_serializing_if = "Option::is_none")] + #[schema(example = 1100.5)] + pub total_chapter_count: Option<f32>, /// Genre names #[serde(default)] @@ -1745,9 +1828,13 @@ pub struct MetadataContextDto { #[serde(default)] pub year_lock: bool, - /// Whether total_book_count is locked + /// Whether total_volume_count is locked + #[serde(default)] + pub total_volume_count_lock: bool, + + /// Whether total_chapter_count is locked #[serde(default)] - pub total_book_count_lock: bool, + pub total_chapter_count_lock: bool, /// Whether genres are locked #[serde(default)] @@ -1861,7 +1948,8 @@ impl From<crate::services::metadata::preprocessing::context::SeriesContext> 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, tags: ctx.metadata.tags, alternate_titles: ctx @@ -1913,7 +2001,8 @@ impl From<crate::services::metadata::preprocessing::context::SeriesContext> 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, tags_lock: ctx.metadata.tags_lock, custom_metadata_lock: ctx.metadata.custom_metadata_lock, diff --git a/src/api/routes/v1/handlers/books.rs b/src/api/routes/v1/handlers/books.rs index 66a6dc5f..8e97486f 100644 --- a/src/api/routes/v1/handlers/books.rs +++ b/src/api/routes/v1/handlers/books.rs @@ -275,6 +275,10 @@ pub async fn books_to_dtos( .and_then(|m| m.number) .map(|d| d.to_string().parse::<i32>().unwrap_or(0)); + // Volume / chapter classification from book_metadata + let volume = book_meta.and_then(|m| m.volume); + let chapter = book_meta.and_then(|m| m.chapter); + let read_progress = progress_map.get(&book.id).cloned(); // Determine effective reading direction: series metadata > library default @@ -305,6 +309,8 @@ pub async fn books_to_dtos( deleted: book.deleted, analyzed: book.analyzed, reading_direction, + volume, + chapter, } }) .collect(); @@ -476,6 +482,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 +535,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 +580,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 +626,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 +1101,7 @@ pub async fn get_book( month: meta.month, day: meta.day, volume: meta.volume, + chapter: meta.chapter, count: meta.count, isbns: meta.isbns, } @@ -1213,6 +1224,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 +1256,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 @@ -2081,6 +2094,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()); @@ -2131,6 +2145,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); } @@ -2176,6 +2193,7 @@ pub async fn replace_book_metadata( month: Set(request.month), day: Set(request.day), volume: Set(request.volume), + chapter: Set(request.chapter), count: Set(request.count), isbns: Set(request.isbns.clone()), // New Phase 1 fields @@ -2207,6 +2225,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(request.chapter.is_some()), count_lock: Set(request.count.is_some()), isbns_lock: Set(request.isbns.is_some()), // New Phase 1 lock fields @@ -2291,6 +2310,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 @@ -2335,6 +2355,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, @@ -2545,6 +2566,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() { @@ -2698,6 +2726,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 @@ -2763,6 +2792,7 @@ pub async fn patch_book_metadata( month: Set(month_opt), day: Set(day_opt), volume: Set(volume_opt), + chapter: Set(chapter_opt), count: Set(count_opt), isbns: Set(isbns_opt.clone()), // New Phase 6 fields @@ -2796,6 +2826,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(chapter_opt.is_some()), count_lock: Set(count_opt.is_some()), isbns_lock: Set(isbns_opt.is_some()), // New Phase 6 lock fields @@ -2882,6 +2913,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 @@ -2926,6 +2958,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, @@ -3016,6 +3049,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 @@ -3138,6 +3172,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); } @@ -3228,6 +3265,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/api/routes/v1/handlers/bulk_metadata.rs b/src/api/routes/v1/handlers/bulk_metadata.rs index 60e22ab0..c6173c18 100644 --- a/src/api/routes/v1/handlers/bulk_metadata.rs +++ b/src/api/routes/v1/handlers/bulk_metadata.rs @@ -84,7 +84,8 @@ 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(); + 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(); @@ -134,8 +135,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 +910,12 @@ 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); + if let Some(v) = locks.total_volume_count { + 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..ffb7f324 100644 --- a/src/api/routes/v1/handlers/plugin_actions.rs +++ b/src/api/routes/v1/handlers/plugin_actions.rs @@ -1079,20 +1079,41 @@ pub async fn preview_series_metadata( &mut not_provided, )); - // Total Book Count + // Total Volume 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))), plugin_metadata - .total_book_count + .total_volume_count .map(|v| serde_json::json!(v)), current_metadata .as_ref() - .map(|m| m.total_book_count_lock) + .map(|m| m.total_volume_count_lock) .unwrap_or(false), - has_permission(PluginPermission::MetadataWriteTotalBookCount), + 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_chapter_count + .map(|v| serde_json::json!(v)), + current_metadata + .as_ref() + .map(|m| m.total_chapter_count_lock) + .unwrap_or(false), + 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..66aaedbc 100644 --- a/src/api/routes/v1/handlers/recommendations.rs +++ b/src/api/routes/v1/handlers/recommendations.rs @@ -384,7 +384,8 @@ 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, + total_volume_count: r.total_volume_count, + total_chapter_count: r.total_chapter_count, rating: r.rating, popularity: r.popularity, } @@ -553,7 +554,8 @@ 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), popularity: Some(50000), }; @@ -582,7 +584,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)); } @@ -607,7 +609,8 @@ 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, popularity: None, }; @@ -627,7 +630,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()); } @@ -655,7 +658,8 @@ 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), popularity: Some(75000), }), @@ -676,7 +680,8 @@ 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, popularity: None, }), @@ -721,7 +726,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); @@ -740,7 +745,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()); } @@ -770,7 +775,8 @@ 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, popularity: None, }], diff --git a/src/api/routes/v1/handlers/series.rs b/src/api/routes/v1/handlers/series.rs index 0ccbc0da..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()), @@ -410,7 +438,8 @@ 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_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 +456,8 @@ 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_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, @@ -765,10 +795,12 @@ 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 title_sort_lock: Set(false), summary_lock: Set(false), @@ -2497,7 +2529,8 @@ 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); + 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 if let Some(ref cm) = request.custom_metadata { @@ -2530,7 +2563,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(), ]), @@ -2552,7 +2586,8 @@ 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_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 @@ -2696,7 +2731,8 @@ 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_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 @@ -2713,7 +2749,8 @@ 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_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, @@ -2821,8 +2858,12 @@ 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); + if let Some(opt) = request.total_volume_count.into_nested_option() { + 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() { @@ -2883,7 +2924,8 @@ 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_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 @@ -3028,7 +3070,8 @@ 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_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 @@ -3045,7 +3088,8 @@ 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_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, @@ -3150,8 +3194,12 @@ 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); + if let Some(v) = request.total_volume_count { + 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 { @@ -3204,7 +3252,8 @@ 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_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, @@ -3262,7 +3311,8 @@ 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_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/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<i32>, pub day: Option<i32>, pub volume: Option<i32>, + /// 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<f32>, pub count: Option<i32>, pub isbns: Option<String>, // 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/entities/plugins.rs b/src/db/entities/plugins.rs index cf15fee4..09db3420 100644 --- a/src/db/entities/plugins.rs +++ b/src/db/entities/plugins.rs @@ -378,9 +378,12 @@ pub enum PluginPermission { /// Update reading direction #[serde(rename = "metadata:write:reading_direction")] MetadataWriteReadingDirection, - /// Update total book 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 @@ -421,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 @@ -457,7 +466,10 @@ 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" + } // Book-specific write permissions PluginPermission::MetadataWriteBookType => "metadata:write:book_type", PluginPermission::MetadataWriteSubtitle => "metadata:write:subtitle", @@ -471,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 @@ -498,7 +512,8 @@ impl PluginPermission { PluginPermission::MetadataWriteAgeRating, PluginPermission::MetadataWriteLanguage, PluginPermission::MetadataWriteReadingDirection, - PluginPermission::MetadataWriteTotalBookCount, + PluginPermission::MetadataWriteTotalVolumeCount, + PluginPermission::MetadataWriteTotalChapterCount, // Book-specific write permissions PluginPermission::MetadataWriteBookType, PluginPermission::MetadataWriteSubtitle, @@ -512,6 +527,8 @@ impl PluginPermission { PluginPermission::MetadataWriteAwards, PluginPermission::MetadataWriteCustomMetadata, PluginPermission::MetadataWriteIsbn, + PluginPermission::MetadataWriteVolume, + PluginPermission::MetadataWriteChapter, ] } @@ -532,7 +549,8 @@ impl PluginPermission { PluginPermission::MetadataWriteAgeRating, PluginPermission::MetadataWriteLanguage, PluginPermission::MetadataWriteReadingDirection, - PluginPermission::MetadataWriteTotalBookCount, + PluginPermission::MetadataWriteTotalVolumeCount, + PluginPermission::MetadataWriteTotalChapterCount, ] } @@ -551,6 +569,8 @@ impl PluginPermission { PluginPermission::MetadataWriteAwards, PluginPermission::MetadataWriteCustomMetadata, PluginPermission::MetadataWriteIsbn, + PluginPermission::MetadataWriteVolume, + PluginPermission::MetadataWriteChapter, ] } } @@ -579,7 +599,12 @@ 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) + } + "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), @@ -593,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 @@ -692,7 +719,8 @@ impl Model { | PluginPermission::MetadataWriteAgeRating | PluginPermission::MetadataWriteLanguage | PluginPermission::MetadataWriteReadingDirection - | PluginPermission::MetadataWriteTotalBookCount + | PluginPermission::MetadataWriteTotalVolumeCount + | PluginPermission::MetadataWriteTotalChapterCount // Book-specific write permissions | PluginPermission::MetadataWriteBookType | PluginPermission::MetadataWriteSubtitle @@ -706,6 +734,8 @@ impl Model { | PluginPermission::MetadataWriteAwards | PluginPermission::MetadataWriteCustomMetadata | PluginPermission::MetadataWriteIsbn + | PluginPermission::MetadataWriteVolume + | PluginPermission::MetadataWriteChapter ) { return true; } @@ -919,6 +949,85 @@ 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)); + } + #[test] fn test_all_write_permissions() { let perms = PluginPermission::all_write_permissions(); @@ -934,8 +1043,12 @@ 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)); + 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] @@ -943,13 +1056,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 15 common permissions - assert_eq!(perms.len(), 15); + // Should have 16 common permissions + assert_eq!(perms.len(), 16); } #[test] @@ -968,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/db/entities/series_metadata.rs b/src/db/entities/series_metadata.rs index 416d1751..b88f1293 100644 --- a/src/db/entities/series_metadata.rs +++ b/src/db/entities/series_metadata.rs @@ -96,13 +96,17 @@ pub struct Model { pub language: Option<String>, // BCP47: "en", "ja", "ko" pub reading_direction: Option<String>, // ltr, rtl, ttb pub year: Option<i32>, - pub total_book_count: Option<i32>, // Expected total (for ongoing series) + /// Total volumes the series will/did have, when known. Use for volume-organized libraries. + pub total_volume_count: Option<i32>, + /// Total chapters the series will/did have, when known. May be fractional (e.g. 47.5). + pub total_chapter_count: Option<f32>, pub custom_metadata: Option<String>, // 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<String>, // Lock fields - pub total_book_count_lock: bool, + 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/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<f32>, + ) -> Result<book_metadata::Model> { + 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<i32>, + ) -> Result<book_metadata::Model> { + 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<book_metadata::Model> { + 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/db/repositories/series.rs b/src/db/repositories/series.rs index a2c441b0..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<SeriesWithRating> for series::Model { } } +/// Per-series aggregates derived from `book_metadata.volume` and +/// `book_metadata.chapter`. +/// +/// Used by series DTOs to render `<max>/<total>` 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 `<bookCount>/<total>` 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<i32>, + /// Highest `book_metadata.chapter` across non-deleted books in the series. + pub local_max_chapter: Option<f32>, + /// 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<i64>, +} + /// Repository for Series operations pub struct SeriesRepository; @@ -753,11 +772,13 @@ 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), title_sort_lock: Set(false), summary_lock: Set(false), @@ -1956,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<BookClassificationAggregates> { + 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<std::collections::HashMap<Uuid, BookClassificationAggregates>> { + 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<i32>, + local_max_chapter: Option<f32>, + volumes_owned: Option<i64>, + } + + // 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<AggRow> = 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::<AggRow>() + .all(db) + .await + .context("Failed to aggregate book classification fields")?; + + let mut map: std::collections::HashMap<Uuid, BookClassificationAggregates> = 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) @@ -3278,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<i32>, + chapter: Option<f32>, + ) -> 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 `<bookCount>/<total>` 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/src/db/repositories/series_metadata.rs b/src/db/repositories/series_metadata.rs index 10dff7a2..9fc27222 100644 --- a/src/db/repositories/series_metadata.rs +++ b/src/db/repositories/series_metadata.rs @@ -68,10 +68,12 @@ 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), title_sort_lock: Set(false), summary_lock: Set(false), @@ -300,11 +302,13 @@ impl SeriesMetadataRepository { Ok(model) } - /// Update total book count (expected number of books in the series) - pub async fn update_total_book_count( + /// 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_book_count: Option<i32>, + total_volume_count: Option<i32>, ) -> Result<series_metadata::Model> { let existing = Self::get_by_series_id(db, series_id) .await? @@ -313,7 +317,31 @@ impl SeriesMetadataRepository { })?; let mut active_model: series_metadata::ActiveModel = existing.into(); - active_model.total_book_count = Set(total_book_count); + 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<f32>, + ) -> Result<series_metadata::Model> { + 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?; @@ -366,7 +394,8 @@ 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), "tags" => active_model.tags_lock = Set(locked), "cover" => active_model.cover_lock = Set(locked), @@ -392,7 +421,8 @@ 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, "tags" => metadata.tags_lock, "cover" => metadata.cover_lock, @@ -664,4 +694,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/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, quick_xml::DeErr &xml_info.editor, ); + // Phase 12 of metadata-count-split: derive a structured `chapter` from + // `<Number>`. ComicInfo's `<Number>` 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::<f32>().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 `<Number>` is the chapter + // axis on the parsed struct. Integer parses cleanly; fractional preserved. + let xml = r#"<?xml version="1.0"?> +<ComicInfo> + <Number>42</Number> +</ComicInfo>"#; + 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#"<?xml version="1.0"?> +<ComicInfo> + <Number>47.5</Number> +</ComicInfo>"#; + let result_frac = parse_comic_info(xml_frac).unwrap(); + assert_eq!(result_frac.chapter, Some(47.5)); + + // No `<Number>` at all -> chapter stays None. + let xml_none = r#"<?xml version="1.0"?> +<ComicInfo> + <Title>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 2bc35583..b08fdb82 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,18 @@ 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 { + // 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 @@ -546,6 +560,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 +601,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.or(comic_info.chapter), count: comic_info.count, isbns: isbns_json, // New Phase 1 fields @@ -618,6 +636,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 +768,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 +809,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 +849,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 +881,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 +1082,68 @@ 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, + chapter: ci.chapter, + }); + + 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 +1194,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: ci.chapter, }); // Build naming context @@ -1514,6 +1609,7 @@ mod tests { month: None, day: None, volume: None, + chapter: None, count: None, isbns: None, // New Phase 1 fields @@ -1545,6 +1641,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/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 a7ef4012..b9b9be7a 100644 --- a/src/services/metadata/apply.rs +++ b/src/services/metadata/apply.rs @@ -336,27 +336,53 @@ impl MetadataApplier { } } - // Total Book Count - if should_apply_field("totalBookCount") - && let Some(total_book_count) = metadata.total_book_count + // 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_book_count_lock) + .map(|m| m.total_volume_count_lock) .unwrap_or(false); match check_field( - "totalBookCount", + "totalVolumeCount", is_locked, - PluginPermission::MetadataWriteTotalBookCount, + PluginPermission::MetadataWriteTotalVolumeCount, ) { Ok(_) => { - SeriesMetadataRepository::update_total_book_count( + SeriesMetadataRepository::update_total_volume_count( db, series_id, - Some(total_book_count), + Some(total_volume_count), ) .await - .context("Failed to update total book count")?; - applied_fields.push("totalBookCount".to_string()); + .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), } 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/src/services/metadata/preprocessing/context.rs b/src/services/metadata/preprocessing/context.rs index 8f734af9..eb8dd93c 100644 --- a/src/services/metadata/preprocessing/context.rs +++ b/src/services/metadata/preprocessing/context.rs @@ -107,7 +107,8 @@ pub struct MetadataContext { pub language: Option, pub reading_direction: Option, pub year: Option, - pub total_book_count: Option, + pub total_volume_count: Option, + pub total_chapter_count: Option, /// Genre names for this series #[serde(default)] @@ -144,7 +145,8 @@ pub struct MetadataContext { pub language_lock: bool, pub reading_direction_lock: bool, pub year_lock: bool, - 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, @@ -348,9 +350,13 @@ impl SeriesContext { .clone() .map(FieldValue::String), "year" => self.metadata.year.map(|v| FieldValue::Number(v as f64)), - "totalBookCount" | "total_book_count" => self + "totalVolumeCount" | "total_volume_count" => self .metadata - .total_book_count + .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" => { @@ -426,8 +432,11 @@ 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)) + } + "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)), @@ -603,7 +612,8 @@ impl SeriesContextBuilder { language: m.language.clone(), reading_direction: m.reading_direction.clone(), year: m.year, - total_book_count: m.total_book_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 +630,8 @@ 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_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, @@ -747,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, @@ -792,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, @@ -886,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), @@ -1078,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(), @@ -1110,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, @@ -1519,7 +1535,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, @@ -1538,8 +1554,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"); @@ -1562,8 +1578,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(), @@ -1575,7 +1591,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"])); @@ -1641,7 +1657,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() }; @@ -1665,7 +1681,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 717b8d03..5d8056e2 100644 --- a/src/services/plugin/library.rs +++ b/src/services/plugin/library.rs @@ -169,7 +169,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..fcaa6715 100644 --- a/src/services/plugin/protocol.rs +++ b/src/services/plugin/protocol.rs @@ -14,8 +14,15 @@ use serde_json::Value; pub const JSONRPC_VERSION: &str = "2.0"; /// Plugin protocol version +/// +/// - 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.0"; +pub const PROTOCOL_VERSION: &str = "1.2"; // ============================================================================= // JSON-RPC Base Types @@ -646,6 +653,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 @@ -764,9 +782,14 @@ pub struct PluginSeriesMetadata { pub year: Option, // Extended metadata - /// Expected total number of books in the series + /// 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_book_count: Option, + 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 +1209,12 @@ pub struct UserLibraryEntry { /// Tags #[serde(default, skip_serializing_if = "Vec::is_empty")] pub tags: Vec, - /// Total book count in the series + /// 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_book_count: Option, + pub total_chapter_count: Option, /// Known external IDs (source → external_id mapping) /// e.g., {"anilist": "12345", "myanimelist": "67890"} @@ -1444,6 +1470,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] @@ -1460,7 +1536,8 @@ 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()), age_rating: Some(13), reading_direction: Some("rtl".to_string()), @@ -1537,7 +1614,8 @@ mod tests { 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, @@ -1558,6 +1636,104 @@ 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)); + + // 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_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_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_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)); + + 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 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] fn test_credential_field() { let field = CredentialField { @@ -2089,7 +2265,8 @@ 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 { source: "anilist".to_string(), external_id: "21".to_string(), @@ -2111,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"); @@ -2132,7 +2310,8 @@ mod tests { status: None, 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, @@ -2236,7 +2415,8 @@ mod tests { status: None, 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..07552657 100644 --- a/src/services/plugin/recommendations.rs +++ b/src/services/plugin/recommendations.rs @@ -126,9 +126,12 @@ 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 volumes in the series, when known. #[serde(default, skip_serializing_if = "Option::is_none")] - pub total_book_count: Option, + 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, @@ -257,7 +260,8 @@ 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![], reading_status: None, books_read: 41, @@ -325,7 +329,8 @@ 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), popularity: Some(120000), }], @@ -337,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"); @@ -394,7 +399,8 @@ 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), popularity: Some(85000), }; @@ -414,7 +420,8 @@ 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); assert_eq!(json["popularity"], 85000); } @@ -444,7 +451,8 @@ 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()); assert!(rec.popularity.is_none()); } @@ -468,7 +476,8 @@ 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, popularity: None, }; @@ -486,6 +495,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")); } @@ -505,7 +516,8 @@ 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![], reading_status: None, books_read: 100, 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/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/komga.rs b/tests/api/komga.rs index 4d464805..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, @@ -3333,6 +3335,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) // ============================================================================ @@ -3528,6 +3579,7 @@ async fn create_book_metadata_full( month, day, volume: None, + chapter: None, count: None, isbns: None, title_lock: false, @@ -3545,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, @@ -3628,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, @@ -3645,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/api/metadata_locks.rs b/tests/api/metadata_locks.rs index 7719d097..6d7ead33 100644 --- a/tests/api/metadata_locks.rs +++ b/tests/api/metadata_locks.rs @@ -294,7 +294,8 @@ 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, authors_json_lock: None, alternate_titles: None, @@ -364,7 +365,8 @@ 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, authors_json_lock: None, alternate_titles: None, @@ -393,7 +395,8 @@ 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, authors_json_lock: None, alternate_titles: None, @@ -446,7 +449,8 @@ 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), authors_json_lock: Some(true), alternate_titles: Some(true), @@ -501,7 +505,8 @@ 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, authors_json_lock: None, alternate_titles: None, @@ -547,7 +552,8 @@ 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, authors_json_lock: None, alternate_titles: None, @@ -603,7 +609,8 @@ 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, authors_json_lock: None, alternate_titles: None, @@ -658,7 +665,8 @@ 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, authors_json_lock: None, alternate_titles: Some(true), @@ -726,7 +734,8 @@ 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, authors_json_lock: None, alternate_titles: Some(true), @@ -796,7 +805,8 @@ 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, authors_json_lock: None, alternate_titles: None, @@ -1132,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, @@ -1208,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, @@ -1303,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/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/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/api/series.rs b/tests/api/series.rs index ca8e65da..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; @@ -2001,7 +2103,8 @@ 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"})), authors: None, }; @@ -2076,7 +2179,8 @@ 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, authors: None, }; @@ -2117,7 +2221,8 @@ 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, authors: None, }; @@ -2163,7 +2268,8 @@ 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, authors: None, }; @@ -4897,7 +5003,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 +5021,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 { @@ -4929,7 +5035,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(); @@ -4980,14 +5086,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(); @@ -5004,7 +5110,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, @@ -5020,7 +5126,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 @@ -5039,7 +5145,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" ); } @@ -5999,7 +6105,8 @@ 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, authors: Some(vec![ serde_json::from_value(serde_json::json!({ @@ -6066,7 +6173,8 @@ 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, authors: Some(vec![ serde_json::from_value(serde_json::json!({ @@ -6098,7 +6206,8 @@ 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, authors: None, }; diff --git a/tests/db/migrations.rs b/tests/db/migrations.rs index 9d6a2098..b8695589 100644 --- a/tests/db/migrations.rs +++ b/tests/db/migrations.rs @@ -365,3 +365,456 @@ 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 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) +} + +#[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 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 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); + + // 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; +} + +// -- 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; +} + +// -- 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 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"); + + 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 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) +} + +#[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; +} + +// -- 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 b910737b..0cafc03b 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 @@ -532,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)); +} diff --git a/tests/services/metadata_apply.rs b/tests/services/metadata_apply.rs index 57ef5fd8..61eca513 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 @@ -67,7 +68,8 @@ 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, age_rating: None, reading_direction: None, @@ -286,3 +288,519 @@ 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_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)); +} diff --git a/web/openapi.json b/web/openapi.json index 20a86c3f..403d3870 100644 --- a/web/openapi.json +++ b/web/openapi.json @@ -17492,6 +17492,15 @@ "description": "Whether the book has been analyzed (page dimensions available)", "example": true }, + "chapter": { + "type": [ + "number", + "null" + ], + "format": "float", + "description": "Chapter number from book metadata (may be fractional)", + "example": 42.5 + }, "createdAt": { "type": "string", "format": "date-time", @@ -17611,6 +17620,15 @@ "format": "date-time", "description": "When the book was last updated", "example": "2024-01-15T10:30:00Z" + }, + "volume": { + "type": [ + "integer", + "null" + ], + "format": "int32", + "description": "Volume number from book metadata", + "example": 1 } } }, @@ -17887,6 +17905,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 +18334,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 +18762,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 +19107,7 @@ "monthLock", "dayLock", "volumeLock", + "chapterLock", "countLock", "isbnsLock", "bookTypeLock", @@ -19095,6 +19145,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 +19371,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", @@ -20397,13 +20461,21 @@ ], "description": "Series status (ongoing, ended, hiatus, abandoned, unknown)" }, - "totalBookCount": { + "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 book count (for ongoing series)" + "description": "Expected total volume count (for volume-organized series)." }, "year": { "type": [ @@ -23302,13 +23374,22 @@ "description": "Custom sort name for ordering", "example": "Batman Year One" }, - "totalBookCount": { + "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 book count (for ongoing series)", + "description": "Expected total volume count (for volume-organized series).", "example": 4 }, "updatedAt": { @@ -23418,6 +23499,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" @@ -23459,6 +23558,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 } } }, @@ -25423,11 +25531,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." } } }, @@ -26304,18 +26412,31 @@ "type": "boolean", "description": "Whether title_sort is locked" }, - "totalBookCount": { + "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 book count", + "description": "Expected total volume count", "example": 110 }, - "totalBookCountLock": { + "totalVolumeCountLock": { "type": "boolean", - "description": "Whether total_book_count is locked" + "description": "Whether total_volume_count is locked" }, "year": { "type": [ @@ -26377,7 +26498,8 @@ "language", "readingDirection", "year", - "totalBookCount", + "totalVolumeCount", + "totalChapterCount", "genres", "tags", "customMetadata", @@ -26461,9 +26583,14 @@ "description": "Whether the title_sort field is locked", "example": false }, - "totalBookCount": { + "totalChapterCount": { + "type": "boolean", + "description": "Whether the total_chapter_count field is locked", + "example": false + }, + "totalVolumeCount": { "type": "boolean", - "description": "Whether the total_book_count field is locked", + "description": "Whether the total_volume_count field is locked", "example": false }, "year": { @@ -27335,6 +27462,15 @@ "description": "Whether the book has been analyzed (page dimensions available)", "example": true }, + "chapter": { + "type": [ + "number", + "null" + ], + "format": "float", + "description": "Chapter number from book metadata (may be fractional)", + "example": 42.5 + }, "createdAt": { "type": "string", "format": "date-time", @@ -27454,6 +27590,15 @@ "format": "date-time", "description": "When the book was last updated", "example": "2024-01-15T10:30:00Z" + }, + "volume": { + "type": [ + "integer", + "null" + ], + "format": "int32", + "description": "Volume number from book metadata", + "example": 1 } } }, @@ -27847,6 +27992,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", @@ -27907,6 +28070,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", @@ -28350,6 +28522,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", @@ -28710,13 +28891,22 @@ "description": "Custom sort name for ordering", "example": "Batman Year One" }, - "totalBookCount": { + "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 book count (for ongoing series)", + "description": "Expected total volume count (for volume-organized series)", "example": 4 }, "year": { @@ -30659,13 +30849,21 @@ "type": "string", "description": "Title of the recommended series/book" }, - "totalBookCount": { + "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 books/volumes in the series" + "description": "Total expected number of volumes in the series." } } }, @@ -30896,6 +31094,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", @@ -31238,13 +31445,22 @@ "description": "Custom sort name for ordering (e.g., \"Batman Year One\" instead of \"The Batman Year One\")", "example": "Batman Year One" }, - "totalBookCount": { + "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 book count (for ongoing series)", + "description": "Expected total volume count (for volume-organized series).", "example": 4 }, "year": { @@ -31646,6 +31862,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": { @@ -31913,7 +32136,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" ], @@ -32145,6 +32368,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", @@ -32205,6 +32446,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", @@ -32513,13 +32763,22 @@ "description": "Custom sort name for ordering", "example": "Batman Year One" }, - "totalBookCount": { + "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 book count (for ongoing series)", + "description": "Expected total volume count (for volume-organized series).", "example": 4 }, "updatedAt": { @@ -32682,13 +32941,22 @@ "description": "Custom sort name for ordering", "example": "Batman Year One" }, - "totalBookCount": { + "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 book count (for ongoing series)", + "description": "Expected total volume count (for volume-organized series).", "example": 4 }, "updatedAt": { @@ -35014,6 +35282,13 @@ ], "description": "Whether to lock book_type" }, + "chapterLock": { + "type": [ + "boolean", + "null" + ], + "description": "Whether to lock chapter" + }, "coloristLock": { "type": [ "boolean", @@ -35493,12 +35768,20 @@ "description": "Whether to lock the title_sort field", "example": false }, - "totalBookCount": { + "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_book_count field", + "description": "Whether to lock the total_volume_count field", "example": false }, "year": { 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/book/BookInfoModal.test.tsx b/web/src/components/book/BookInfoModal.test.tsx index 50e36cd1..24968fb0 100644 --- a/web/src/components/book/BookInfoModal.test.tsx +++ b/web/src/components/book/BookInfoModal.test.tsx @@ -83,6 +83,52 @@ describe("BookInfoModal", () => { expect(screen.getByText("Comics")).toBeInTheDocument(); }); + it("should display volume and chapter with classification badge when both set", () => { + renderWithProviders( + , + ); + + expect(screen.getByText("Volume")).toBeInTheDocument(); + expect(screen.getByText("15")).toBeInTheDocument(); + expect(screen.getByText("Chapter")).toBeInTheDocument(); + expect(screen.getByText("126")).toBeInTheDocument(); + expect(screen.getByText("Classification")).toBeInTheDocument(); + expect(screen.getByText("Vol 15 · Ch 126")).toBeInTheDocument(); + }); + + it("should show chapter-only classification when only chapter is set", () => { + renderWithProviders( + , + ); + + expect(screen.getByText("Chapter")).toBeInTheDocument(); + expect(screen.getByText("Classification")).toBeInTheDocument(); + expect(screen.getByText("Ch 42")).toBeInTheDocument(); + expect(screen.queryByText("Volume")).not.toBeInTheDocument(); + }); + + it("should not display Volume/Chapter rows when neither is set", () => { + renderWithProviders( + , + ); + + expect(screen.queryByText("Volume")).not.toBeInTheDocument(); + expect(screen.queryByText("Chapter")).not.toBeInTheDocument(); + expect(screen.queryByText("Classification")).not.toBeInTheDocument(); + }); + it("should display file information", () => { renderWithProviders( + + + {hasVolumeOrChapter && ( + + + Classification + + + + )} {book.titleSort && ( )} diff --git a/web/src/components/book/BookKindBadge.test.tsx b/web/src/components/book/BookKindBadge.test.tsx new file mode 100644 index 00000000..5562e072 --- /dev/null +++ b/web/src/components/book/BookKindBadge.test.tsx @@ -0,0 +1,62 @@ +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("uses the chapter color (grape) when both volume and chapter are set", () => { + renderWithProviders(); + const badge = screen + .getByText("Vol 15 · Ch 126") + .closest(".mantine-Badge-root") as HTMLElement | null; + expect(badge).not.toBeNull(); + // Mantine renders the badge color into a CSS custom property on the root + const styleColor = badge?.getAttribute("style") ?? ""; + expect(styleColor).toMatch(/grape/); + }); + + it("uses the volume color (blue) when only volume is set", () => { + renderWithProviders(); + const badge = screen + .getByText("Vol 7") + .closest(".mantine-Badge-root") as HTMLElement | null; + expect(badge).not.toBeNull(); + const styleColor = badge?.getAttribute("style") ?? ""; + expect(styleColor).toMatch(/blue/); + }); + + 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..01ae2bf4 --- /dev/null +++ b/web/src/components/book/BookKindBadge.tsx @@ -0,0 +1,68 @@ +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. + * + * Two semantic colors: + * - blue = volume (a complete volume — chapter null) + * - grape = chapter (a chapter, with or without a parent volume) + * + * Cases: + * - volume set, chapter null → blue "Vol N" + * - chapter set, volume null → grape "Ch N" + * - both set → grape "Vol V · Ch C" (still a chapter) + * - 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; + + // Chapter (with or without parent volume) — grape + if (hasChapter) { + return ( + + {hasVolume + ? `Vol ${volume} · Ch ${formatChapter(chapter)}` + : `Ch ${formatChapter(chapter)}`} + + ); + } + + // Volume only — blue + if (hasVolume) { + return ( + + Vol {volume} + + ); + } + + // 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/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/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 (