Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions changelog.d/2524-split-editions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
### Fixed
- **Split-edition catalogue rows no longer count as missing books you already own** (#2524): some Hardcover catalogs list a novel's own split-into-parts editions beside the whole novel (The Way of Kings at position 1, with "Part 1" and "Part 2" at 1.1 and 1.2). Already owning the whole novel left those parts listed as missing and offered an add button that queued a duplicate download. They now show in their own collapsed section, linked to the book that covers them, and Fill gaps no longer creates them. Thanks magrhino for the Stormlight and Wheel of Time catalogue data that shaped the rule.
4 changes: 2 additions & 2 deletions docs/Hardcover-Series-Wiki.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ When enhanced Hardcover series data is enabled, Bindery can:
- search Hardcover for the matching catalog series
- link or unlink a local series to a Hardcover series
- compare the local series against the Hardcover catalog
- show present, missing, local-only, and uncertain catalog entries
- show present, missing, local-only, uncertain, and covered (split-edition) catalog entries
- add one missing Hardcover book, or add all missing books for a linked series
- create wanted and monitored book rows for missing catalog entries and queue searches for them

Expand Down Expand Up @@ -93,7 +93,7 @@ The fill action may create new authors and books from Hardcover metadata when th

When fill re-queues the books already in the series, it also leaves out any that are unmonitored and not imported and that the profile's skip part books or skip missing release date filter rejects, so a fill no longer puts those back on Wanted. A book that is already monitored, for example one you added by hand, is queued as before. A book with no known language is not rejected by the language filter during a fill, whatever the profile does with unknown languages, because Hardcover series entries carry no language; it is resolved later. The other filters need a fresh provider lookup; use **More → Reconcile catalogue…** on the author to apply every filter to books already stored.

Some catalogs list a novel's own split edition beside it, such as "The Way of Kings, Part 1" and "Part 2" at 1.1 and 1.2 under "The Way of Kings" at 1. A book in the series counts as such a split part when both hold: its position is a fraction under the whole's position, and its title is the whole's title followed by "Part N". When the whole is imported, or monitored and wanted, fill does not re-queue the parts and the scheduled wanted search does not search them, so a part of a book you already have is never downloaded. The parts are marked **Split part** on the series card and are not counted as missing. If a fill from an older version left them monitored, **Unmonitor N split parts** on the series card unmonitors them in one click. Nothing is deleted, and an imported part is never touched. A real novella at a fractional position ("Edgedancer" at 2.5) has no "Part N" marker and stays a normal book, and parts with no whole at the integer position are the real volumes and are searched as usual.
Some catalogs list a novel's own split edition beside it, such as "The Way of Kings, Part 1" and "Part 2" at 1.1 and 1.2 under "The Way of Kings" at 1. A book counts as such a split part when both hold: its position is a fraction under the whole's position, and its title is the whole's title followed by "Part N" (a subtitle after the marker is ignored). Position alone is not enough: a real novella at a fractional position, such as "Edgedancer" at 2.5 beside "Words of Radiance" at 2, has no "Part N" marker and stays a normal book, and a catalog that represents a work only as its split parts, with no unsplit row at the integer position at all (Robert Jordan's Wheel of Time "Two Volume Edition" catalog is like this), never triggers the rule either, since there is nothing to cover the parts. On the Hardcover catalog diff a covered part is reported as covered, not missing: it is not counted, not offered an **add** button, and not created by fill, and appears in its own collapsed section on the Series page ("N split editions of books you own") linking to the whole that covers it. The same rule covers rows already stored locally: once the whole is imported, or monitored and not excluded, fill no longer re-queues a stored part and the scheduled wanted search no longer searches it, so a part of a book you already have is never downloaded. Stored parts are marked **Split part** on the series card and are not counted as missing. If a fill from an older version left one monitored, **Unmonitor N split parts** on the series card unmonitors them in one click. Nothing is deleted, and an imported part is never touched.

The format dropdown beside **add all** sets the media type of every book the fill creates. Pick **Ebook** and the created books are ebook only, even when Hardcover lists an audiobook edition of the same work, so only one search is queued per book. Pick **Audiobook** for audio only. Pick **Both** if you want Bindery to look for both formats. Books that are already in the series keep whatever media type they were added with, so change those on the book itself.

Expand Down
105 changes: 97 additions & 8 deletions internal/api/series.go
Original file line number Diff line number Diff line change
Expand Up @@ -914,14 +914,19 @@ type seriesHardcoverDiffBook struct {
}

type seriesHardcoverDiffResponse struct {
SeriesID int64 `json:"seriesId"`
Link *models.SeriesHardcoverLink `json:"link"`
Present []seriesHardcoverDiffBook `json:"present"`
Missing []seriesHardcoverDiffBook `json:"missing"`
LocalOnly []seriesHardcoverDiffBook `json:"localOnly"`
Uncertain []seriesHardcoverDiffBook `json:"uncertain"`
PresentCount int `json:"presentCount"`
MissingCount int `json:"missingCount"`
SeriesID int64 `json:"seriesId"`
Link *models.SeriesHardcoverLink `json:"link"`
Present []seriesHardcoverDiffBook `json:"present"`
Missing []seriesHardcoverDiffBook `json:"missing"`
LocalOnly []seriesHardcoverDiffBook `json:"localOnly"`
Uncertain []seriesHardcoverDiffBook `json:"uncertain"`
// Covered is a catalogue row that is not a missing volume but a split
// edition (#2524) of a book already present: the row's own LocalBookID/
// LocalTitle point at the whole work that covers it, not at a row of its
// own. Never counted in MissingCount, never offered to Fill.
Covered []seriesHardcoverDiffBook `json:"covered"`
PresentCount int `json:"presentCount"`
MissingCount int `json:"missingCount"`
}

var (
Expand Down Expand Up @@ -1432,6 +1437,7 @@ func buildHardcoverDiff(ctx context.Context, books *db.BookRepo, userID int64, s
Missing: []seriesHardcoverDiffBook{},
LocalOnly: []seriesHardcoverDiffBook{},
Uncertain: []seriesHardcoverDiffBook{},
Covered: []seriesHardcoverDiffBook{},
}
// matchedCatalog doubles as the exclusion set for bestCatalogMatch: a
// catalog entry a previous local book already claimed is off the table for
Expand All @@ -1440,6 +1446,12 @@ func buildHardcoverDiff(ctx context.Context, books *db.BookRepo, userID int64, s
// and pushed the second local's real catalog entry into Missing behind an
// Add button that ensureHardcoverCatalogBook's guards then refuse.
matchedCatalog := make(map[int]struct{})
// presentWholes collects the subset of Present rows eligible to cover a
// split-edition part (#2524/#3048): not excluded, and imported or
// monitored, the same gate db.ListCoveredSplitEditionParts applies to
// stored rows, so the diff and that sweep never disagree about which
// wholes cover which parts.
presentWholes := make([]presentWhole, 0, len(series.Books))
// Identities bind first (#2553). Assigning in library order let an
// earlier local's fuzzy title match claim the catalogue slot that a later
// local owned by foreign ID: "He Who Fights with Monsters 4" took volume
Expand Down Expand Up @@ -1534,6 +1546,14 @@ func buildHardcoverDiff(ctx context.Context, books *db.BookRepo, userID int64, s
if match.score >= 90 || match.foreignID {
diff.Present = append(diff.Present, item)
matchedCatalog[match.index] = struct{}{}
if eligibleSplitEditionWhole(local.Book) {
presentWholes = append(presentWholes, presentWhole{
position: item.Position,
title: item.Title,
localBookID: local.Book.ID,
localTitle: local.Book.Title,
})
}
} else if match.score >= 70 {
diff.Uncertain = append(diff.Uncertain, item)
matchedCatalog[match.index] = struct{}{}
Expand All @@ -1553,6 +1573,14 @@ func buildHardcoverDiff(ctx context.Context, books *db.BookRepo, userID int64, s
if title := firstNonEmpty(book.Book.Title, book.Title); metadata.IsUnambiguousBundleTitle(title) {
continue
}
if whole, ok := splitEditionWhole(book, presentWholes); ok {
item := catalogDiffBook(book, catalog.AuthorName)
id := whole.localBookID
item.LocalBookID = &id
item.LocalTitle = whole.localTitle
diff.Covered = append(diff.Covered, item)
continue
}
item := catalogDiffBook(book, catalog.AuthorName)
enrichMissingDiffBook(ctx, books, userID, book, &item)
diff.Missing = append(diff.Missing, item)
Expand All @@ -1562,6 +1590,67 @@ func buildHardcoverDiff(ctx context.Context, books *db.BookRepo, userID int64, s
return diff
}

// presentWhole is the subset of a Present row that splitEditionWhole needs:
// a catalogue diff row the library holds, eligible to cover a split-edition
// part (#2524/#3048). Kept separate from seriesHardcoverDiffBook so the
// eligibility gate (not excluded, imported or monitored) is decided once,
// where local.Book is still in scope, rather than re-derived from the API
// response shape.
type presentWhole struct {
position string
title string
localBookID int64
localTitle string
}

// eligibleSplitEditionWhole reports whether book is a book the user actually
// has or wants, so it is allowed to cover a split-edition part (#3048): not
// excluded, and imported or monitored.
//
// This MUST stay in agreement with the SQL predicate in
// db.ListCoveredSplitEditionParts (internal/db/split_edition_parts.go),
// which applies the identical rule to rows already stored. The two cannot
// share one literal implementation across the SQL/Go boundary, so an
// eligibility change here needs the same change made there, and vice versa,
// or the catalogue diff and the stored-row sweep will disagree about which
// wholes cover which parts.
func eligibleSplitEditionWhole(b *models.Book) bool {
return !b.Excluded && (b.Status == models.BookStatusImported || b.Monitored)
}

// splitEditionWhole reports whether book is a split-edition part of some
// already-present, eligible whole work, and if so returns that whole.
//
// #2524/#3048: some Hardcover catalogues list a novel AND its split parts at
// fractional positions under the novel's own position — Stormlight's "The
// Way of Kings" at 1 alongside "…, Part 1" and "…, Part 2" at 1.1 and 1.2.
// Owning the whole novel should not leave its own parts reading as missing
// volumes, counting against the series, and sitting behind an Add button
// that would create and queue a download for text already on the shelf.
//
// The two-signal rule itself is seriesmatch.SplitEditionPartOf, shared with
// db.ListCoveredSplitEditionParts (#3048), which applies the same rule to
// rows already stored for an install that filled before this landed. A
// series represented ONLY by its split parts — Wheel of Time's "Two Volume
// Edition", where there is no unsplit whole at the integer position — never
// matches this, because there is no eligible whole to match against; its
// parts are the real volumes and stay Missing.
//
// wholes is pre-filtered to Present rows that are also eligible (not
// excluded, imported or monitored), so an excluded or purely-wanted,
// unmonitored Present row covers nothing — the same gate the stored-row
// sweep applies, so the two never disagree about which wholes cover which
// parts.
func splitEditionWhole(book metadata.SeriesCatalogBook, wholes []presentWhole) (presentWhole, bool) {
partTitle := firstNonEmpty(book.Book.Title, book.Title)
for _, whole := range wholes {
if seriesmatch.SplitEditionPartOf(partTitle, book.Position, whole.title, whole.position) {
return whole, true
}
}
return presentWhole{}, false
}

// logDiffDecision records which catalogue entry a local series book bound
// to, and how. The diff matcher had no logging at all, so a wrong binding
// could only be diagnosed by rebuilding the library in a scratch container
Expand Down
Loading
Loading