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/1970-duplicate-candidates.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
### Added
- **Review duplicate titles for an author** (#1970): a new read only report, opened from the author page under **More, Review duplicates**, groups books whose titles look like the same work and says which rule matched: a punctuation or accent variant, a leading article, an edition marker such as Unabridged, or one title being the main title or subtitle of the other ("Mistborn" and "Mistborn: The Final Empire"). That last rule only counts at a colon, bracket or spaced dash, so a series opener like "Foundation" is not flagged against "Foundation and Empire", and it is skipped for two books that are known, different entries in the same series. Nothing changes automatically; excluding a row uses the existing exclude action, so nothing is ever deleted. Thanks gchahcg for building it.
2 changes: 2 additions & 0 deletions changelog.d/2480-skip-tracked-manual-import.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
### Fixed
- **Importing from a folder no longer lists files that are already in your library** (#2480). A file Bindery already tracks, whether at the same path, as a hardlink of a library file, or as a confident match whose book already has that format, is left out of the scan, so rescanning a downloads folder stops offering things you already brought in. Tick **Show already imported** on the Import page to list them again, labelled, for example to replace a corrupt copy. Thanks to @trevorswanson for the fix.
1 change: 1 addition & 0 deletions cmd/bindery/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -976,6 +976,7 @@ func main() {
r.Post("/author/{id}/catalogue-reconciliation", authorHandler.ApplyCatalogueReconciliation)
r.Get("/author/{id}/relink-upstream/candidates", authorHandler.RelinkCandidates)
r.Post("/author/{id}/relink-upstream", authorHandler.RelinkUpstream)
r.Get("/author/{id}/duplicate-candidates", authorHandler.DuplicateCandidates)
r.Get("/author/{id}/series", authorHandler.ListSeries)
r.Get("/author/{id}/aliases", authorAliasHandler.List)
r.Delete("/author/{id}/aliases/{aliasID}", authorAliasHandler.Delete)
Expand Down
43 changes: 41 additions & 2 deletions docs/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,10 @@ POST /api/v1/author/{id}/refresh re-pull profile and works, ski
GET /api/v1/author/{id}/catalogue-reconciliation
preview stale metadata-only Wanted rows
POST /api/v1/author/{id}/catalogue-reconciliation
recheck and remove selected preview rows
GET /api/v1/author/{id}/relink-upstream/candidates
recheck and remove selected preview rows
GET /api/v1/author/{id}/duplicate-candidates
read-only groups of titles that look like the same book (#1970)
GET /api/v1/author/{id}/relink-upstream/candidates
search metadata candidates for manual relink
POST /api/v1/author/{id}/relink-upstream re-bind to a different foreign ID
GET /api/v1/author/{id}/aliases list merged-in alias rows
Expand Down Expand Up @@ -181,6 +183,43 @@ move that author onto the fallback (#2271). Retry once the primary is
answering, or send an explicit `foreignAuthorId`, which skips the search
entirely.

`GET /api/v1/author/{id}/duplicate-candidates` reports groups of the author's
books whose titles look like the same book, for human review (#1970). It is
read-only: it never writes, and the review UI's only action is the existing
`PUT /book/{id}/exclude`. Titles are compared with an aggressive fold (case,
punctuation, and diacritics ignored; `&` expanded to "and"), and a pair joins a
group when any of these match: `alnum-equal` (identical after the fold),
`article-strip` (identical after dropping a leading article),
`edition-suffix` (identical after dropping a trailing edition marker), or
`substring` (one title is the whole main title or subtitle of the other, split
at a colon, bracket, or spaced dash, with length guards; "Foundation" does not
match "Foundation and Empire"). `substring` is additionally suppressed when
both books are known, different positions in the same series, so "Mistborn"
at position 1 does not match "Mistborn: The Well of Ascension" at position 2.
Groups are linked transitively, so A≈B and B≈C lands in one group.
The response is:

```json
{
"authorId": 42,
"count": 1,
"groups": [
{
"key": "themartian",
"rules": ["article-strip", "substring"],
"books": [
{ "id": 101, "title": "The Martian", "excluded": false, "rules": ["article-strip"] },
{ "id": 102, "title": "Martian", "excluded": true, "rules": ["article-strip"] }
]
}
]
}
```

Each `books` entry is the full book object plus a per-book `rules` list; the
group's `rules` is the union across its members. Groups whose members are all
excluded are omitted, and `count` is the number of groups returned.

### Books

```
Expand Down
26 changes: 22 additions & 4 deletions docs/User-Guide-Wiki.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,10 +44,12 @@ an arbitrary release is unreliable. The escape hatches:
- **Import** (`/import`): **In your library** lists the books a library scan
could not match, for you to adopt in place ([Adopting files already in your
library](#adopting-files-already-in-your-library)). **From a folder** points
at a folder of files you already have and matches them to books. Either way a
file with no catalogue match gets a metadata search, which creates the book
(and its author, if new) and links the file to it, so an unmatched file is no
longer a dead end.
at a folder of files you already have and matches them to books; files already
tracked in the library are skipped by default, and toggling **Show already
imported** surfaces them again, e.g. to spot a corrupted file or relink one.
Either way a file with no catalogue match gets a metadata search, which
creates the book (and its author, if new) and links the file to it, so an
unmatched file is no longer a dead end.

Both still end by attaching a file to a catalogue record.

Expand Down Expand Up @@ -828,6 +830,22 @@ current catalogue; changing providers alone is not evidence that a work is
obsolete. A correlated work that the metadata profile explicitly rejects can
still be removed for that rejection reason.

**Duplicate titles.** The same book often reaches the catalogue twice under
slightly different titles — "The Martian" and "Martian", "Dune" and "Dune
(Unabridged)". Open the author and choose **More → Review duplicates…** to see
groups of titles that look like the same book. Each group shows which rule
matched (identical after normalisation, a leading article dropped, an edition
marker dropped, or one title being the main title or subtitle of the other),
and each row shows the rules that pulled it in. A main title or subtitle match
only counts at a colon, bracket or spaced dash, so Asimov's "Foundation" is not
flagged against "Foundation and Empire", and it is skipped when the two books
are known, different entries in the same series ("Mistborn" against "Mistborn:
The Well of Ascension"). Nothing is changed automatically: the only action is
**Exclude** on a row you judge to be the duplicate, which marks it excluded
without deleting anything. An excluded row stays in its group, struck through,
with an **Include** button to undo it; a group leaves the report once fewer
than two of its rows are still included.

## How author names are filed

One value decides the order of the Authors list, the order of the OPDS author
Expand Down
84 changes: 84 additions & 0 deletions internal/api/authors_duplicate_candidates.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
package api

import (
"net/http"

"github.com/vavallee/bindery/internal/duplicates"
)

// duplicateCandidatesResponse is the payload for GET
// /author/{id}/duplicate-candidates (#1970).
type duplicateCandidatesResponse struct {
AuthorID int64 `json:"authorId"`
Groups []duplicates.Group `json:"groups"`
Count int `json:"count"`
}

// DuplicateCandidates implements GET /author/{id}/duplicate-candidates
// (#1970): a read-only, human-reviewed duplicate-title report for one
// author's catalogue. It scans every book row — excluded rows included, so a
// group whose members have already been excluded can be re-shown as "all
// excluded" instead of vanishing — keeps only groups with at least two
// non-excluded members, and returns them annotated with the rule(s) that
// matched so the UI can explain each group in plain language.
//
// The endpoint writes nothing: acting on a candidate is the existing exclude
// route (PUT /book/{id}/exclude), which the UI calls only after a human
// confirms. That is the whole safety model of #1970 — aggressive detection,
// human in the loop, no silent merges.
//
// It also passes each book's series memberships to Scan (#1970 review), so
// the substring rule can be suppressed between two books that are different,
// known positions in the same series — a long first title that doubles as
// the series name ("Foundation" vs "Foundation and Empire") would otherwise
// group with its own sequels. h.series is optional (nil in tests that don't
// need it); without it, this simply falls back to the pre-review behaviour.
func (h *AuthorHandler) DuplicateCandidates(w http.ResponseWriter, r *http.Request) {
author, ok := h.loadOwnedAuthor(w, r)
if !ok {
return
}
books, err := h.books.ListByAuthorIncludingExcluded(r.Context(), author.ID)
if err != nil {
writeServerError(w, r, err)
return
}
var seriesSlots map[int64][]duplicates.SeriesSlot
if h.series != nil {
memberships, err := h.series.ListBookSeriesMembershipsByAuthor(r.Context(), author.ID)
if err != nil {
writeServerError(w, r, err)
return
}
seriesSlots = make(map[int64][]duplicates.SeriesSlot, len(memberships))
for bookID, ms := range memberships {
for _, m := range ms {
seriesSlots[bookID] = append(seriesSlots[bookID], duplicates.SeriesSlot{
SeriesID: m.SeriesID,
Position: m.Position,
})
}
}
}
groups := duplicates.Scan(books, seriesSlots)
active := groups[:0]
for i := range groups {
n := 0
for _, m := range groups[i].Members {
if !m.Excluded {
n++
}
}
if n >= 2 {
active = append(active, groups[i])
}
}
if active == nil {
active = []duplicates.Group{}
}
writeJSON(w, http.StatusOK, duplicateCandidatesResponse{
AuthorID: author.ID,
Groups: active,
Count: len(active),
})
}
Loading
Loading