diff --git a/CHANGELOG.md b/CHANGELOG.md index e5a519faf..5a407a662 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,42 @@ All notable changes to Bindery are documented here. Format loosely follows [Keep a Changelog](https://keepachangelog.com) and versions follow [Semantic Versioning](https://semver.org). +## [v1.39.0] - 2026-09-29 + +Calibre that never misses a book, a pull mode for desktop Calibre, and fixes for series volumes and wrong author grabs + +Books bound for Calibre now wait in a queue instead of being pushed once and forgotten, so anything imported while Calibre is closed arrives when it comes back, failures retry on their own, and the Calibre tab shows what is waiting and why something failed. Every ebook format of a book now lands on the same Calibre record, and a desktop Calibre can fetch books from Bindery itself with the Bindery Bridge plugin 0.8.0, which needs no shared drive, path remap or open port. A new Windows runbook walks through both setups. + +Adding or scanning one volume of a numbered series no longer attaches another volume's file, and a release that names a different author after a series label is no longer grabbed. That author check now runs on every automatic and interactive search, not just a fallback path. Transmission downloads survive a daemon restart, a folder import stops offering files already in your library, and switching metadata providers no longer offers still valid books for removal. This release adds one database migration, additive, which runs on its own at startup. + +### Added +- **Every ebook format of a book now reaches the same Calibre record with the Bindery Bridge plugin 0.7.0 or later** (#2832). A book with an EPUB and a PDF used to get only one of them into Calibre. Now the preferred format makes the record (EPUB, then KEPUB, AZW3, MOBI, PDF, then the rest) and the others are added to it, from an import and from Push all to Calibre alike. With an older plugin, or in calibredb mode, the extra formats are skipped with a reason telling you to update the plugin, and once you do Bindery delivers them on its own. +- **Push all to Calibre now queues books, and the Calibre tab shows what is waiting, what failed and why** (#2832). Push all puts every book that is not already delivered into the delivery queue instead of sending them itself, so Calibre does not need to be open and a book is never sent twice. A new Delivery queue section shows how many books are waiting, delivered and failed, when the last one arrived and whether Calibre could be reached, with a table of failed books and their errors, **Retry failed**, **Clear waiting**, and **Reset delivery state** for pointing Bindery at a different Calibre library. Each book's page shows whether it is waiting for Calibre, in Calibre or failed. +- **Calibre can now fetch books from Bindery instead of waiting for Bindery to send them** (#2833). Set the Calibre plugin transport to **Pull** and the Calibre Bridge plugin connects out to Bindery, downloads each queued book and adds it, so a desktop Calibre no longer needs the library on a shared drive, a push path remap, an open inbound port or a fixed address. It uses the plugin API key you already have, which must be at least 16 characters, and it needs Calibre Bridge 0.8.0 or later. The Calibre tab shows when the plugin last checked in. +- **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. + +### Changed +- **Books imported while Calibre is closed or unreachable now reach it when it comes back** (#2832). Imports queue each ebook for Calibre instead of pushing it on the spot, so an import no longer waits on Calibre. A background job delivers the queue within about a minute of Calibre answering, retries failures with growing gaps and marks a book failed after 8 attempts. **Push all to Calibre** now sends covers too. + +### Fixed +- **Calibre Test connection checks a real book, not just the library root** (#2831): with the Bindery Bridge plugin, Test connection only asked whether Calibre could see the library root. That path matches the push path remap exactly and skips the part that builds a book's path, so Test passed while every push was about to fail. It now also asks Calibre to open one real book (the newest imported ebook, or the first file under the library root) through the remap, and names the exact path it sent when Calibre cannot see it. When that path starts with a drive letter, it explains that a mapped drive belongs to one Windows logon session and suggests the share address instead. Paths in these messages no longer show doubled backslashes. Test connection also warns when the bridge is older than 0.6.2, which fixes pushes to long network share paths (0.6.1) and the empty book a failed add could leave in Calibre (0.6.2). +- **Network share paths work in path remaps** (#2831): a remap that pointed at a Windows share, such as a Calibre push remap of `/downloads/BOOKS:\\192.168.1.4\MEDIA\BOOKS`, was treated as a Linux path, so pushes asked Calibre for `\\192.168.1.4\MEDIA\BOOKS/Author/file.epub` with mixed separators, and the forward slash form `//nas/books` lost its leading slash and stopped being a share at all. Shares now behave like drive letters: `\\server\share` and `//server/share` are both understood on either side of a pair, matched without regard to case, and rebuilt in the separator style you wrote. A share that names a server but no share, like `\\nas`, is now rejected when you save it. For Calibre on Windows a share address is more reliable than a mapped drive letter, which the running Calibre may not be able to see. One thing to check if you already use a remap that starts with two forward slashes: a side like `//data/books` now counts as the share `books` on the server `data`, so it matches without regard to case. A side with a single segment after the slashes, like `//data`, is unchanged. +- **Push all to Calibre stops listing wanted books as skipped**: a book that has not been imported has no file, so it was never going to be pushed, and listing every one of them under Skipped as "not imported" buried the books you could actually do something about. On a library with a few thousand wanted books the sample of 50 filled up with them before any unmonitored book or missing file was reached. Skipped now counts only imported books the run left out, with the reason for each. +- **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. +- **Print editions no longer mistaken for audiobooks over a stray "cd"** (#2769): Hardcover edition text was checked for "cd" anywhere, so a name like McDermott could mark a print edition as audio, give the book an ASIN it should not have and widen its media type. "cd" now has to be a word on its own, so Audio CD, 2 CDs and CD-ROM still count. Thanks to @mavonx for the fix. +- **Transmission downloads survive a daemon restart** (#2808): Bindery stored the numeric id Transmission hands out when a torrent is added, and Transmission renumbers every torrent when it restarts. After a restart a download could sit at downloading for good, or have its status, import and queue removal land on whichever torrent inherited the number, including deleting that torrent's data. Transmission downloads are now tracked by info hash, which never changes. Downloads grabbed before this release are matched back to their torrent on the next poll by when they were added, with the release name breaking ties, and are only rewritten when the match is unambiguous. One that cannot be matched is never removed by its old id: remove it in Transmission yourself. Thanks dClauzel for the report and ccarpinteri for the fix. +- **Adding the next volume of a series no longer attaches the first volume's file** (#2810): when you add a book, Bindery checks whether the library already has it and, if so, attaches the file and skips the search. That check matched titles by shared words alone, so "Defiance of the Fall 17" matched volume 1's file. In a layout like `Defiance of the Fall 01/Defiance of the Fall_B094JZMCJX_….m4b`, where the number is only in the folder name, the new volume was marked owned, never searched, and nothing new reached Audiobookshelf. The check now compares volume numbers, whether they appear in the title or only in the book folder, the way the series comparison has since #2538, and it never attaches a file that already belongs to another book. Part numbers on a split audiobook and track numbers inside a numbered folder are not mistaken for volumes. The same volume rule applies when a finished download is matched to a book. Thanks magrhino for the report and the fix. +- **Existing library files found when adding an author are recorded properly** (#2819): if Bindery could not load the book while recording a file it already found in your library, it used to mark the book imported without registering the file, and reported success even when the book did not exist. It now logs the real error instead. Thanks to @magrhino for the report. +- **Catalogue reconciliation keeps unmatched previous-provider books** (#2827): Switching metadata providers no longer makes uncorrelated metadata-only Wanted rows look safe to remove. Complete same-provider absence and explicit profile rejections remain actionable. Thanks to @kevinatlee for the fix. +- **A library scan no longer attaches one volume of a series to another** (#2860): the scan matched untracked files to wanted books by how alike the titles look, and two volumes of a numbered series look almost identical. An untracked `Defiance of the Fall 01` folder was attached to a wanted "Defiance of the Fall 17", which was then marked owned with the wrong file and never searched. The scan now compares volume numbers first, whether they appear in the title or only in the book folder, using the same rule as adding a book since #2810. The file goes to the volume it really is, or waits under Import, In your library, where the other volume is no longer suggested for it. Numbers that are part of a title, like Fahrenheit 451 or Catch-22, part numbers on a split audiobook and track numbers inside a numbered folder are not mistaken for volumes. Thanks magrhino for the report. +- **A series label no longer hides a release's author** (#2863): a release like "Heart of Glass, Skye Druids (03) by Donna Grant EPUB" was grabbed for Jennifer Hillier's "Heart of Glass", because the author check only read "by " when it came straight after the title. It now also reads it after a series name and number, so a same titled book by someone else is dropped, while the right author, an initial ("J. Hillier"), surname first order, or a coauthor still match. Narrator and translator credits are still ignored, and a release with no author at all is kept as before. The check also now runs on automatic and interactive searches, not only on the fallback path. Thanks dallyboytx for the report and the diagnosis. +- **Manual import scan with a symlinked library folder** (#2868): files already in your library no longer show up as importable when the library folder is reached through a symlink and the file was offline when Bindery last indexed it or has been edited since. The scan tests now pass on macOS and the macOS lint run is clean again. Thanks magrhino for the report. + +### Docs +- **The Calibre on Windows runbook now leads with pull mode** (#2834). Pull needs no shared drive, path remap, inbound firewall rule or fixed PC address, so it is the recommended way to connect a desktop Calibre, with push kept for Calibre in a container or on a server. The Calibre docs and the quick answers now describe the delivery queue (books wait while Calibre is closed, failures retry with backoff, Retry failed instead of rerunning Push all), multi format delivery, how to keep the pull key safe, and the plugin status messages seen in a real setup, including why a `localhost` Bindery URL can make every request take about 20 seconds on Windows. +- **Calibre on a Windows desktop runbook** (#2834): a step by step guide for connecting the Calibre desktop app on Windows through the Bindery Bridge plugin, built from a real setup. It covers using the share address instead of a mapped drive letter, installing and checking the plugin, the firewall rule and network profile, a fixed address for the PC, what each Test connection message means, reading the Push all results, and rerunning Push all to recover. The user guide gains Quick answers for the Calibre symptoms seen along the way, including how to get books onto a Kobo. +- **Issue and pull request templates**: bug reports ask for the book and the exact release name behind search and import problems and list Unraid as a deployment method, feature requests ask who the feature is for, and the pull request template asks for evidence that a fix's test fails without it, spells out the sign off and licence rules, and names the changelog fragment convention. + ## [v1.38.0] - 2026-09-27 Retry failed downloads, profile ordered releases, a library wide reorganize, and a big round of Hardcover and audiobook fixes diff --git a/changelog.d/1970-duplicate-candidates.md b/changelog.d/1970-duplicate-candidates.md deleted file mode 100644 index 62877c439..000000000 --- a/changelog.d/1970-duplicate-candidates.md +++ /dev/null @@ -1,2 +0,0 @@ -### 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. diff --git a/changelog.d/2480-skip-tracked-manual-import.md b/changelog.d/2480-skip-tracked-manual-import.md deleted file mode 100644 index d80149615..000000000 --- a/changelog.d/2480-skip-tracked-manual-import.md +++ /dev/null @@ -1,2 +0,0 @@ -### 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. diff --git a/changelog.d/2769-cd-audio-marker.md b/changelog.d/2769-cd-audio-marker.md deleted file mode 100644 index 5233c17a6..000000000 --- a/changelog.d/2769-cd-audio-marker.md +++ /dev/null @@ -1,2 +0,0 @@ -### Fixed -- **Print editions no longer mistaken for audiobooks over a stray "cd"** (#2769): Hardcover edition text was checked for "cd" anywhere, so a name like McDermott could mark a print edition as audio, give the book an ASIN it should not have and widen its media type. "cd" now has to be a word on its own, so Audio CD, 2 CDs and CD-ROM still count. Thanks to @mavonx for the fix. diff --git a/changelog.d/2808-transmission-info-hash.md b/changelog.d/2808-transmission-info-hash.md deleted file mode 100644 index acfdf39b6..000000000 --- a/changelog.d/2808-transmission-info-hash.md +++ /dev/null @@ -1,2 +0,0 @@ -### Fixed -- **Transmission downloads survive a daemon restart** (#2808): Bindery stored the numeric id Transmission hands out when a torrent is added, and Transmission renumbers every torrent when it restarts. After a restart a download could sit at downloading for good, or have its status, import and queue removal land on whichever torrent inherited the number, including deleting that torrent's data. Transmission downloads are now tracked by info hash, which never changes. Downloads grabbed before this release are matched back to their torrent on the next poll by when they were added, with the release name breaking ties, and are only rewritten when the match is unambiguous. One that cannot be matched is never removed by its old id: remove it in Transmission yourself. Thanks dClauzel for the report and ccarpinteri for the fix. diff --git a/changelog.d/2810-findexisting-volume-number.md b/changelog.d/2810-findexisting-volume-number.md deleted file mode 100644 index f7f571e14..000000000 --- a/changelog.d/2810-findexisting-volume-number.md +++ /dev/null @@ -1,2 +0,0 @@ -### Fixed -- **Adding the next volume of a series no longer attaches the first volume's file** (#2810): when you add a book, Bindery checks whether the library already has it and, if so, attaches the file and skips the search. That check matched titles by shared words alone, so "Defiance of the Fall 17" matched volume 1's file. In a layout like `Defiance of the Fall 01/Defiance of the Fall_B094JZMCJX_….m4b`, where the number is only in the folder name, the new volume was marked owned, never searched, and nothing new reached Audiobookshelf. The check now compares volume numbers, whether they appear in the title or only in the book folder, the way the series comparison has since #2538, and it never attaches a file that already belongs to another book. Part numbers on a split audiobook and track numbers inside a numbered folder are not mistaken for volumes. The same volume rule applies when a finished download is matched to a book. Thanks magrhino for the report and the fix. diff --git a/changelog.d/2819-setfilepath-errors.md b/changelog.d/2819-setfilepath-errors.md deleted file mode 100644 index e200f71f5..000000000 --- a/changelog.d/2819-setfilepath-errors.md +++ /dev/null @@ -1,2 +0,0 @@ -### Fixed -- **Existing library files found when adding an author are recorded properly** (#2819): if Bindery could not load the book while recording a file it already found in your library, it used to mark the book imported without registering the file, and reported success even when the book did not exist. It now logs the real error instead. Thanks to @magrhino for the report. diff --git a/changelog.d/2827-reconcile-cross-provider-indeterminate.md b/changelog.d/2827-reconcile-cross-provider-indeterminate.md deleted file mode 100644 index 20541a1ba..000000000 --- a/changelog.d/2827-reconcile-cross-provider-indeterminate.md +++ /dev/null @@ -1,2 +0,0 @@ -### Fixed -- **Catalogue reconciliation keeps unmatched previous-provider books** (#2827): Switching metadata providers no longer makes uncorrelated metadata-only Wanted rows look safe to remove. Complete same-provider absence and explicit profile rejections remain actionable. Thanks to @kevinatlee for the fix. diff --git a/changelog.d/2831-calibre-test-probe.md b/changelog.d/2831-calibre-test-probe.md deleted file mode 100644 index 566a8dc4e..000000000 --- a/changelog.d/2831-calibre-test-probe.md +++ /dev/null @@ -1,2 +0,0 @@ -### Fixed -- **Calibre Test connection checks a real book, not just the library root** (#2831): with the Bindery Bridge plugin, Test connection only asked whether Calibre could see the library root. That path matches the push path remap exactly and skips the part that builds a book's path, so Test passed while every push was about to fail. It now also asks Calibre to open one real book (the newest imported ebook, or the first file under the library root) through the remap, and names the exact path it sent when Calibre cannot see it. When that path starts with a drive letter, it explains that a mapped drive belongs to one Windows logon session and suggests the share address instead. Paths in these messages no longer show doubled backslashes. Test connection also warns when the bridge is older than 0.6.2, which fixes pushes to long network share paths (0.6.1) and the empty book a failed add could leave in Calibre (0.6.2). diff --git a/changelog.d/2831-pathmap-unc.md b/changelog.d/2831-pathmap-unc.md deleted file mode 100644 index e4ed38bfa..000000000 --- a/changelog.d/2831-pathmap-unc.md +++ /dev/null @@ -1,2 +0,0 @@ -### Fixed -- **Network share paths work in path remaps** (#2831): a remap that pointed at a Windows share, such as a Calibre push remap of `/downloads/BOOKS:\\192.168.1.4\MEDIA\BOOKS`, was treated as a Linux path, so pushes asked Calibre for `\\192.168.1.4\MEDIA\BOOKS/Author/file.epub` with mixed separators, and the forward slash form `//nas/books` lost its leading slash and stopped being a share at all. Shares now behave like drive letters: `\\server\share` and `//server/share` are both understood on either side of a pair, matched without regard to case, and rebuilt in the separator style you wrote. A share that names a server but no share, like `\\nas`, is now rejected when you save it. For Calibre on Windows a share address is more reliable than a mapped drive letter, which the running Calibre may not be able to see. One thing to check if you already use a remap that starts with two forward slashes: a side like `//data/books` now counts as the share `books` on the server `data`, so it matches without regard to case. A side with a single segment after the slashes, like `//data`, is unchanged. diff --git a/changelog.d/2832-calibre-deliveries-table.md b/changelog.d/2832-calibre-deliveries-table.md deleted file mode 100644 index db399bd3a..000000000 --- a/changelog.d/2832-calibre-deliveries-table.md +++ /dev/null @@ -1,2 +0,0 @@ -### Changed -- **Bindery now records which books it has sent to Calibre** (#2832), in preparation for retrying deliveries that fail while Calibre is unreachable, and books already pushed are recorded when you upgrade. diff --git a/changelog.d/2832-calibre-delivery-worker.md b/changelog.d/2832-calibre-delivery-worker.md deleted file mode 100644 index f74a6e91e..000000000 --- a/changelog.d/2832-calibre-delivery-worker.md +++ /dev/null @@ -1,2 +0,0 @@ -### Changed -- **Books imported while Calibre is closed or unreachable now reach it when it comes back** (#2832). Imports queue each ebook for Calibre instead of pushing it on the spot, so an import no longer waits on Calibre. A background job delivers the queue within about a minute of Calibre answering, retries failures with growing gaps and marks a book failed after 8 attempts. **Push all to Calibre** now sends covers too. diff --git a/changelog.d/2832-calibre-multi-format.md b/changelog.d/2832-calibre-multi-format.md deleted file mode 100644 index ce6c3a4c6..000000000 --- a/changelog.d/2832-calibre-multi-format.md +++ /dev/null @@ -1,2 +0,0 @@ -### Added -- **Every ebook format of a book now reaches the same Calibre record with the Bindery Bridge plugin 0.7.0 or later** (#2832). A book with an EPUB and a PDF used to get only one of them into Calibre. Now the preferred format makes the record (EPUB, then KEPUB, AZW3, MOBI, PDF, then the rest) and the others are added to it, from an import and from Push all to Calibre alike. With an older plugin, or in calibredb mode, the extra formats are skipped with a reason telling you to update the plugin, and once you do Bindery delivers them on its own. diff --git a/changelog.d/2832-calibre-queue-ui.md b/changelog.d/2832-calibre-queue-ui.md deleted file mode 100644 index 33fcad9c4..000000000 --- a/changelog.d/2832-calibre-queue-ui.md +++ /dev/null @@ -1,2 +0,0 @@ -### Added -- **Push all to Calibre now queues books, and the Calibre tab shows what is waiting, what failed and why** (#2832). Push all puts every book that is not already delivered into the delivery queue instead of sending them itself, so Calibre does not need to be open and a book is never sent twice. A new Delivery queue section shows how many books are waiting, delivered and failed, when the last one arrived and whether Calibre could be reached, with a table of failed books and their errors, **Retry failed**, **Clear waiting**, and **Reset delivery state** for pointing Bindery at a different Calibre library. Each book's page shows whether it is waiting for Calibre, in Calibre or failed. diff --git a/changelog.d/2832-delivery-test-races.md b/changelog.d/2832-delivery-test-races.md deleted file mode 100644 index 9da7c7864..000000000 --- a/changelog.d/2832-delivery-test-races.md +++ /dev/null @@ -1,2 +0,0 @@ -### Fixed -- **Calibre delivery tests no longer fail under the race detector**: two tests added with the delivery queue raced with their own background work. No change to how Bindery behaves. diff --git a/changelog.d/2833-bridge-pull-routes.md b/changelog.d/2833-bridge-pull-routes.md deleted file mode 100644 index 95ac46480..000000000 --- a/changelog.d/2833-bridge-pull-routes.md +++ /dev/null @@ -1,2 +0,0 @@ -### Added -- **Calibre can now fetch books from Bindery instead of waiting for Bindery to send them** (#2833). Set the Calibre plugin transport to **Pull** and the Calibre Bridge plugin connects out to Bindery, downloads each queued book and adds it, so a desktop Calibre no longer needs the library on a shared drive, a push path remap, an open inbound port or a fixed address. It uses the plugin API key you already have, which must be at least 16 characters, and it needs Calibre Bridge 0.8.0 or later. The Calibre tab shows when the plugin last checked in. diff --git a/changelog.d/2834-calibre-pull-docs.md b/changelog.d/2834-calibre-pull-docs.md deleted file mode 100644 index 49b5112ba..000000000 --- a/changelog.d/2834-calibre-pull-docs.md +++ /dev/null @@ -1,2 +0,0 @@ -### Docs -- **The Calibre on Windows runbook now leads with pull mode** (#2834). Pull needs no shared drive, path remap, inbound firewall rule or fixed PC address, so it is the recommended way to connect a desktop Calibre, with push kept for Calibre in a container or on a server. The Calibre docs and the quick answers now describe the delivery queue (books wait while Calibre is closed, failures retry with backoff, Retry failed instead of rerunning Push all), multi format delivery, how to keep the pull key safe, and the plugin status messages seen in a real setup, including why a `localhost` Bindery URL can make every request take about 20 seconds on Windows. diff --git a/changelog.d/2834-calibre-windows-runbook.md b/changelog.d/2834-calibre-windows-runbook.md deleted file mode 100644 index 26c42fc4a..000000000 --- a/changelog.d/2834-calibre-windows-runbook.md +++ /dev/null @@ -1,2 +0,0 @@ -### Docs -- **Calibre on a Windows desktop runbook** (#2834): a step by step guide for connecting the Calibre desktop app on Windows through the Bindery Bridge plugin, built from a real setup. It covers using the share address instead of a mapped drive letter, installing and checking the plugin, the firewall rule and network profile, a fixed address for the PC, what each Test connection message means, reading the Push all results, and rerunning Push all to recover. The user guide gains Quick answers for the Calibre symptoms seen along the way, including how to get books onto a Kobo. diff --git a/changelog.d/2860-scan-volume-veto.md b/changelog.d/2860-scan-volume-veto.md deleted file mode 100644 index c7199d6d1..000000000 --- a/changelog.d/2860-scan-volume-veto.md +++ /dev/null @@ -1,2 +0,0 @@ -### Fixed -- **A library scan no longer attaches one volume of a series to another** (#2860): the scan matched untracked files to wanted books by how alike the titles look, and two volumes of a numbered series look almost identical. An untracked `Defiance of the Fall 01` folder was attached to a wanted "Defiance of the Fall 17", which was then marked owned with the wrong file and never searched. The scan now compares volume numbers first, whether they appear in the title or only in the book folder, using the same rule as adding a book since #2810. The file goes to the volume it really is, or waits under Import, In your library, where the other volume is no longer suggested for it. Numbers that are part of a title, like Fahrenheit 451 or Catch-22, part numbers on a split audiobook and track numbers inside a numbered folder are not mistaken for volumes. Thanks magrhino for the report. diff --git a/changelog.d/calibre-push-skip-not-imported.md b/changelog.d/calibre-push-skip-not-imported.md deleted file mode 100644 index e466865b3..000000000 --- a/changelog.d/calibre-push-skip-not-imported.md +++ /dev/null @@ -1,2 +0,0 @@ -### Fixed -- **Push all to Calibre stops listing wanted books as skipped**: a book that has not been imported has no file, so it was never going to be pushed, and listing every one of them under Skipped as "not imported" buried the books you could actually do something about. On a library with a few thousand wanted books the sample of 50 filled up with them before any unmonitored book or missing file was reached. Skipped now counts only imported books the run left out, with the reason for each. diff --git a/changelog.d/issue-pr-templates.md b/changelog.d/issue-pr-templates.md deleted file mode 100644 index 37277153b..000000000 --- a/changelog.d/issue-pr-templates.md +++ /dev/null @@ -1,2 +0,0 @@ -### Docs -- **Issue and pull request templates**: bug reports ask for the book and the exact release name behind search and import problems and list Unraid as a deployment method, feature requests ask who the feature is for, and the pull request template asks for evidence that a fix's test fails without it, spells out the sign off and licence rules, and names the changelog fragment convention. diff --git a/internal/api/manual_import_cache.go b/internal/api/manual_import_cache.go index ce9c0a2d4..9b8438a23 100644 --- a/internal/api/manual_import_cache.go +++ b/internal/api/manual_import_cache.go @@ -78,9 +78,15 @@ func (h *ManualImportHandler) trackedFileIndex(ctx context.Context, scanRoot str } tracked := make(map[string]struct{}, len(trackedPaths)) trackedFiles := make([]os.FileInfo, 0, len(trackedPaths)) + dirs := newDirResolver() for _, trackedPath := range trackedPaths { cleaned := filepath.Clean(trackedPath) tracked[cleaned] = struct{}{} + // Scan walks the symlink-resolved folder (resolveImportFolder), but + // book_files holds paths in whatever form the library root was + // configured, so a root reached through a symlink never matched here + // by path (#2868). Index the resolved form too. + tracked[dirs.resolvePath(cleaned)] = struct{}{} info, statErr := os.Stat(cleaned) //nolint:gosec // #nosec G304 -- path comes from book_files, populated only by prior imports through this same admin-gated handler if statErr != nil { continue @@ -114,3 +120,49 @@ func (h *ManualImportHandler) trackedFileIndex(ctx context.Context, scanRoot str c.trackedFiles = trackedFiles return tracked, trackedFiles, nil } + +// dirResolver maps a path to the form Scan sees after resolveImportFolder's +// symlink resolution, so book_files rows written under a symlinked library +// root still match the scanned units by exact path (#2868). Only directory +// components are resolved; the leaf is kept as-is because the scan walk +// reports a symlinked file under its own name, not its target's. +// +// filepath.EvalSymlinks per tracked row would cost an lstat per component per +// row on a cold rebuild, and fails outright for a path that is missing at +// rebuild time, which is exactly the case the exact-path entry exists for (a +// file on an unmounted share has no FileInfo to hardlink-match against). +// Resolving one component at a time with a per-directory memo costs one lstat +// per distinct directory in the library and still resolves the existing +// prefix of a missing path. +type dirResolver struct { + memo map[string]string +} + +func newDirResolver() *dirResolver { + return &dirResolver{memo: make(map[string]string)} +} + +func (r *dirResolver) resolvePath(p string) string { + dir := filepath.Dir(p) + if dir == p { + return p + } + return filepath.Join(r.resolveDir(dir), filepath.Base(p)) +} + +func (r *dirResolver) resolveDir(dir string) string { + if out, ok := r.memo[dir]; ok { + return out + } + out := dir + if parent := filepath.Dir(dir); parent != dir { + out = filepath.Join(r.resolveDir(parent), filepath.Base(dir)) + if fi, err := os.Lstat(out); err == nil && fi.Mode()&os.ModeSymlink != 0 { + if real, err := filepath.EvalSymlinks(out); err == nil { + out = real + } + } + } + r.memo[dir] = out + return out +} diff --git a/internal/api/manual_import_test.go b/internal/api/manual_import_test.go index f5684e925..14df3db96 100644 --- a/internal/api/manual_import_test.go +++ b/internal/api/manual_import_test.go @@ -1146,6 +1146,19 @@ func scanRequest(path string) *http.Request { return httptest.NewRequest(http.MethodGet, u, nil) } +// resolvedTempDir returns t.TempDir() with symlinks resolved. Scan reports +// paths under the symlink-resolved folder, and on macOS t.TempDir() sits under +// /var, a symlink to /private/var, so expectations built from the raw temp dir +// never match (#2868). +func resolvedTempDir(t *testing.T) string { + t.Helper() + dir, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { + t.Fatal(err) + } + return dir +} + func writeTestFile(t *testing.T, path string) { t.Helper() if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { @@ -1298,7 +1311,7 @@ func TestManualImportScan_SkipsAlreadyTrackedFiles(t *testing.T) { h := NewManualImportHandler(stub, downloads, books) trackedBook := seedBook(t, authors, books, ctx) - root := t.TempDir() + root := resolvedTempDir(t) tracked := filepath.Join(root, "already-imported.epub") newFile := filepath.Join(root, "new-book.epub") writeTestFile(t, tracked) @@ -1407,7 +1420,7 @@ func TestManualImportScan_LargeTrackedRegionDoesNotStarveTheCap(t *testing.T) { trackedBook := seedBook(t, authors, books, ctx) stub := &stubManualImportScanner{lookupResult: importer.LookupResult{Match: "none"}} h := NewManualImportHandler(stub, downloads, books) - root := t.TempDir() + root := resolvedTempDir(t) const trackedCount = maxScanEntries + 5 for i := 0; i < trackedCount; i++ { @@ -1534,7 +1547,7 @@ func TestManualImportScan_IncludeImportedSurfacesTrackedUnits(t *testing.T) { books := db.NewBookRepo(database) downloads := db.NewDownloadRepo(database) trackedBook := seedBook(t, authors, books, ctx) - root := t.TempDir() + root := resolvedTempDir(t) // Exact-path tracked file (filter 1). trackedPath := filepath.Join(root, "already-imported.epub") @@ -1604,7 +1617,7 @@ func TestManualImportScan_RevealsFileAfterBookDeletedByCascade(t *testing.T) { books := db.NewBookRepo(database) downloads := db.NewDownloadRepo(database) trackedBook := seedBook(t, authors, books, ctx) - root := t.TempDir() + root := resolvedTempDir(t) tracked := filepath.Join(root, "tracked.epub") writeTestFile(t, tracked) if err := books.AddBookFile(ctx, trackedBook.ID, models.MediaTypeEbook, tracked); err != nil { @@ -1663,7 +1676,7 @@ func TestManualImportScan_SeesReorganizeMoveBetweenScans(t *testing.T) { books := db.NewBookRepo(database) downloads := db.NewDownloadRepo(database) trackedBook := seedBook(t, authors, books, ctx) - root := t.TempDir() + root := resolvedTempDir(t) oldPath := filepath.Join(root, "old.epub") newPath := filepath.Join(root, "Author", "new.epub") writeTestFile(t, oldPath) @@ -1749,7 +1762,7 @@ func TestManualImportScan_ReusedInodeIsNotTreatedAsTracked(t *testing.T) { books := db.NewBookRepo(database) downloads := db.NewDownloadRepo(database) trackedBook := seedBook(t, authors, books, ctx) - root := t.TempDir() + root := resolvedTempDir(t) libDir := filepath.Join(root, "library") dlDir := filepath.Join(root, "downloads") if err := os.MkdirAll(libDir, 0o755); err != nil { @@ -1827,7 +1840,7 @@ func TestManualImportScan_TrackedPathMissingAtRebuildStillHidden(t *testing.T) { books := db.NewBookRepo(database) downloads := db.NewDownloadRepo(database) trackedBook := seedBook(t, authors, books, ctx) - root := t.TempDir() + root := resolvedTempDir(t) tracked := filepath.Join(root, "away.epub") if err := books.AddBookFile(ctx, trackedBook.ID, models.MediaTypeEbook, tracked); err != nil { t.Fatalf("attach tracked file: %v", err) @@ -1853,6 +1866,64 @@ func TestManualImportScan_TrackedPathMissingAtRebuildStillHidden(t *testing.T) { } } +// TestManualImportScan_SymlinkedLibraryRootMatchesTrackedPaths covers a +// library root configured through a symlink (#2868). Scan walks the resolved +// folder, while book_files holds paths under the configured symlink, so the +// exact-path filter never matched and only the inode fallback hid tracked +// files. That fallback cannot help a tracked file that was missing when the +// index was built (an unmounted share) or was rewritten in place since (size +// and mtime changed), and both surfaced as importable. +func TestManualImportScan_SymlinkedLibraryRootMatchesTrackedPaths(t *testing.T) { + database, err := db.OpenMemory() + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { database.Close() }) + + ctx := context.Background() + authors := db.NewAuthorRepo(database) + books := db.NewBookRepo(database) + downloads := db.NewDownloadRepo(database) + trackedBook := seedBook(t, authors, books, ctx) + + realRoot := resolvedTempDir(t) + linkRoot := filepath.Join(resolvedTempDir(t), "library") + if err := os.Symlink(realRoot, linkRoot); err != nil { + t.Skipf("symlinks unavailable: %v", err) + } + + away := filepath.Join(linkRoot, "Author", "away.epub") + edited := filepath.Join(linkRoot, "Author", "edited.epub") + writeTestFile(t, edited) + for _, p := range []string{away, edited} { + if err := books.AddBookFile(ctx, trackedBook.ID, models.MediaTypeEbook, p); err != nil { + t.Fatalf("attach tracked file %s: %v", p, err) + } + } + stub := &stubManualImportScanner{lookupResult: importer.LookupResult{Match: "none"}} + h := NewManualImportHandler(stub, downloads, books).WithRoots(NewLibraryRoots(nil, linkRoot)) + + rec := httptest.NewRecorder() + h.Scan(rec, scanRequest(linkRoot)) // builds the index while away.epub is absent + if rec.Code != http.StatusOK { + t.Fatalf("first scan: %d %s", rec.Code, rec.Body.String()) + } + + writeTestFile(t, away) // the mount comes back + if err := os.WriteFile(edited, []byte("rewritten in place"), 0o644); err != nil { + t.Fatal(err) + } + rec2 := httptest.NewRecorder() + h.Scan(rec2, scanRequest(linkRoot)) + var resp ScanResponse + if err := json.Unmarshal(rec2.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode: %v", err) + } + if len(resp.Items) != 0 { + t.Fatalf("items = %+v, want both tracked files hidden by path through the symlinked root", resp.Items) + } +} + // TestManualImportHandler_TrackedFileIndex_CachesUntilBookFilesChange // verifies the tracked-file index is rebuilt only when book_files actually // changes (#2480): mutating the map trackedFileIndex returns and calling it diff --git a/internal/api/samedevice_unix.go b/internal/api/samedevice_unix.go index e0e90a921..13b46b1ce 100644 --- a/internal/api/samedevice_unix.go +++ b/internal/api/samedevice_unix.go @@ -57,7 +57,12 @@ func deviceIDOf(fi os.FileInfo) (uint64, bool) { if !ok { return 0, false } - return uint64(st.Dev), true + // Stat_t.Dev is uint64 on linux and freebsd but int32 on darwin and + // openbsd, where gosec reports G115 (#2868). The result is only ever + // compared for equality against another deviceIDOf/deviceID result, never + // used as a size or index, and int32 to uint64 sign extension is + // injective, so distinct device ids stay distinct and equal ones equal. + return uint64(st.Dev), true //nolint:gosec // G115: opaque equality key, conversion is injective on every platform (see above) } // deviceID returns path's OS device ID as a cache key: two paths returning diff --git a/internal/indexer/debug.go b/internal/indexer/debug.go index 82150e5f7..fa87fb968 100644 --- a/internal/indexer/debug.go +++ b/internal/indexer/debug.go @@ -307,6 +307,19 @@ func filterRelevantDebug(results []newznab.SearchResult, title, author string, a var dropped []FilterDebug for i, r := range results { n := normTitles[i] + // The same attribution guard filterRelevant runs. Production search + // (auto-grab and the interactive panel) comes through here, so without + // it a same titled book by another author is kept and grabbed (#2863). + if conflictingTitleAuthor(r.Title, title, authorTokenSets) || + conflictingTitleAuthor(r.Title, primaryTitle(title), authorTokenSets) { + dropped = append(dropped, FilterDebug{ + Title: r.Title, + IndexerName: r.IndexerName, + Stage: "relevance", + Reason: "release names a different author for this title", + }) + continue + } fullOK := tryMatch(n, fullKws) || tryMatchElided(n, fullElided, fullKws) primaryOK := false if !fullOK && len(primaryKws) > 0 && !sameKws(primaryKws, fullKws) { diff --git a/internal/indexer/title_identity.go b/internal/indexer/title_identity.go index 29adcdda0..5f13ed81d 100644 --- a/internal/indexer/title_identity.go +++ b/internal/indexer/title_identity.go @@ -36,6 +36,9 @@ func conflictingTitleAuthor(release, title string, authorSets [][]string) bool { } } } + if attribution == "" { + attribution = seriesLabelAttribution(normalizedRelease, normalizedTitle) + } if attribution == "" { return false } @@ -59,3 +62,84 @@ func conflictingTitleAuthor(release, title string, authorSets [][]string) bool { } return knownAuthor } + +// maxSeriesLabelTokens caps how many words may sit between the requested title +// and a later "by" for that "by" to still be read as the book's attribution. +// A series name plus an index ("Skye Druids 03", "Home Repair Is Homicide 12") +// fits comfortably; a longer run is more likely a different, longer title or +// free text, which the title keyword checks already judge. +const maxSeriesLabelTokens = 8 + +// creditRoleWords are the words that turn a following "by" into a credit that +// is not the author: a narrator, translator, illustrator, or the uploader of +// the release. "Narrated by Donna Grant" says nothing about who wrote the book. +var creditRoleWords = map[string]bool{ + "read": true, "narrated": true, "narrator": true, "performed": true, + "translated": true, "translation": true, "illustrated": true, "illustrations": true, + "foreword": true, "introduction": true, "afterword": true, "introduced": true, + "uploaded": true, "posted": true, "ripped": true, "encoded": true, + "scanned": true, "shared": true, "you": true, // "brought to you by" +} + +// seriesLabelAttribution reads ", <series> <index> by <author>" (#2863), +// where a series label sits between the requested title and the credit so the +// "<title> by <author>" prefix check in conflictingTitleAuthor never sees it: +// "Heart of Glass, Skye Druids (03) by Donna Grant EPUB". +// +// Both arguments are NormalizeRelease output, so punctuation and brackets are +// already gone and "by" can only match as a whole word, never inside one +// ("Abby"). Only the part of the release AFTER the requested title is searched, +// so a "by" inside the title itself ("Stand by Me", "Death by Chocolate") is +// consumed by the title prefix and not mistaken for an attribution. +// +// The attribution is the words after the first such "by", up to the first +// token that describes the file rather than a person (a format, a release +// marker such as "retail", or a year or other digit led token). It returns "" +// when there is no such "by", when the label before it is implausibly long, or +// when the "by" credits a narrator, translator or uploader; the caller then +// accepts the release on its title exactly as before. +func seriesLabelAttribution(normalizedRelease, normalizedTitle string) string { + if normalizedTitle == "" { + return "" + } + rest, ok := strings.CutPrefix(normalizedRelease, normalizedTitle+" ") + if !ok { + return "" + } + toks := strings.Fields(rest) + for i, tok := range toks { + if tok != "by" { + continue + } + if i == 0 || i > maxSeriesLabelTokens || creditRoleWords[toks[i-1]] { + return "" + } + var attribution []string + for _, a := range toks[i+1:] { + if attributionStopToken(a) { + break + } + attribution = append(attribution, a) + } + return strings.Join(attribution, " ") + } + return "" +} + +// attributionStopToken reports whether tok ends a "by <author>" attribution: +// it describes the file (format, release marker) or is digit led (a year, +// bitrate or size), none of which is part of a person's name. +func attributionStopToken(tok string) bool { + if tok[0] >= '0' && tok[0] <= '9' { + return true + } + if releaseMetaTokens[tok] { + return true + } + for _, f := range formatTokens { + if tok == f { + return true + } + } + return false +} diff --git a/internal/indexer/title_identity_test.go b/internal/indexer/title_identity_test.go index 19dd9262a..200afe880 100644 --- a/internal/indexer/title_identity_test.go +++ b/internal/indexer/title_identity_test.go @@ -1,9 +1,14 @@ package indexer import ( + "context" + "net/http" + "net/http/httptest" + "slices" "testing" "github.com/vavallee/bindery/internal/indexer/newznab" + "github.com/vavallee/bindery/internal/models" ) // These releases were auto-grabbed and imported as three different books. @@ -62,3 +67,119 @@ func TestFilterRelevantIdentityCompatibility(t *testing.T) { }) } } + +// seriesLabelRelevanceFilters runs the #2863 table through both filter entry +// points. filterRelevantDebug is the one production runs (automatic search via +// SearchBookWithOutcomes, interactive search via SearchBookWithDebug); +// filterRelevant is the fallback for searchers that cannot report outcomes. +var seriesLabelRelevanceFilters = []struct { + name string + fn func([]newznab.SearchResult, string, string, []string) []newznab.SearchResult +}{ + {"filterRelevant", filterRelevant}, + {"filterRelevantDebug", func(r []newznab.SearchResult, title, author string, aliases []string) []newznab.SearchResult { + kept, _ := filterRelevantDebug(r, title, author, aliases) + return kept + }}, +} + +// A series label between the title and "by <author>" used to hide the +// attribution, so a same titled book by someone else was grabbed (#2863). +func TestFilterRelevantSeriesLabelAttribution(t *testing.T) { + cases := []struct { + title, author, release string + want bool + }{ + // The reported grab. + {"Heart of Glass", "Jennifer Hillier", "Heart of Glass, Skye Druids (03) by Donna Grant EPUB", false}, + {"Heart of Glass", "Jennifer Hillier", "Heart of Glass, Skye Druids (03) by Jennifer Hillier EPUB", true}, + {"Heart of Glass", "Jennifer Hillier", "Heart of Glass, Skye Druids (03) by J. Hillier EPUB", true}, + {"Heart of Glass", "Jennifer Hillier", "Heart of Glass, Skye Druids (03) by Hillier, Jennifer EPUB", true}, + {"Heart of Glass", "Jennifer Hillier", "Heart of Glass, Skye Druids (03) by Hillier EPUB", true}, + {"Heart of Glass", "Jennifer Hillier", "Heart of Glass, Skye Druids (03) by Donna Grant and Jennifer Hillier EPUB", true}, + {"Heart of Glass", "Jennifer Hillier", "Heart of Glass, Skye Druids (03) narrated by Donna Grant MP3", true}, + {"Heart of Glass", "Jennifer Hillier", "Heart of Glass, Skye Druids (03) EPUB", true}, + {"Heart of Glass", "Jennifer Hillier", "Heart of Glass EPUB", true}, + // Other index shapes. + {"Heart of Glass", "Jennifer Hillier", "Heart of Glass, Skye Druids #3 by Jennifer Hillier EPUB", true}, + {"Heart of Glass", "Jennifer Hillier", "Heart of Glass, Skye Druids #3 by Donna Grant EPUB", false}, + {"Heart of Glass", "Jennifer Hillier", "Heart of Glass (Skye Druids Book 2) by Donna Grant", false}, + {"Heart of Glass", "Jennifer Hillier", "Heart of Glass (Skye Druids Book 2) by Jennifer Hillier", true}, + {"Heart of Glass", "Jennifer Hillier", "Heart of Glass, Skye Druids 3 by Donna Grant [retail] 2019", false}, + // The attribution ends at the first file token; a name after it is not the author. + {"Heart of Glass", "Jennifer Hillier", "Heart of Glass, Skye Druids 3 by Donna Grant (2019) [for fans of Jennifer Hillier]", false}, + // Unchanged: the separator form was already read as an attribution. + {"Heart of Glass", "Jennifer Hillier", "Heart of Glass - Skye Druids 03 - Donna Grant", false}, + // A title that contains "by" itself. + {"Stand by Me", "Neil Jones", "Stand by Me by Neil Jones EPUB", true}, + {"Stand by Me", "Neil Jones", "Stand by Me by Donna Grant EPUB", false}, + {"Stand by Me", "Neil Jones", "Stand by Me, Heartland #2 by Neil Jones EPUB", true}, + {"Stand by Me", "Neil Jones", "Stand by Me, Heartland #2 by Donna Grant EPUB", false}, + {"Death by Chocolate", "Sarah Graves", "Death by Chocolate, Home Repair Is Homicide 12 by Sarah Graves", true}, + {"Death by Chocolate", "Sarah Graves", "Death by Chocolate, Home Repair Is Homicide 12 by Donna Grant", false}, + {"Death by Chocolate", "Sarah Graves", "Death by Chocolate EPUB", true}, + // No author requested: nothing to conflict with. + {"Heart of Glass", "", "Heart of Glass, Skye Druids (03) by Donna Grant EPUB", true}, + } + for _, f := range seriesLabelRelevanceFilters { + for _, c := range cases { + t.Run(f.name+"/"+c.release, func(t *testing.T) { + got := f.fn(toResults(c.release), c.title, c.author, nil) + if (len(got) == 1) != c.want { + t.Fatalf("%s(%q, %q, %q): kept=%v, want %v", f.name, c.release, c.title, c.author, len(got) == 1, c.want) + } + }) + } + } +} + +// Every author token set (the primary name and each bound alias) is tried +// against the attribution read after a series label. +func TestConflictingTitleAuthorSeriesLabelAlias(t *testing.T) { + const release = "Heart of Glass, Skye Druids (03) by Jenny Hill EPUB" + primary := authorTokens("Jennifer Hillier") + alias := authorTokens("Jenny Hill") + if !conflictingTitleAuthor(release, "Heart of Glass", [][]string{primary}) { + t.Fatalf("%q not read as a conflicting attribution without the alias", release) + } + if conflictingTitleAuthor(release, "Heart of Glass", [][]string{primary, alias}) { + t.Fatalf("%q rejected although its attribution matches an alias", release) + } +} + +// The #2863 release must be dropped by the entry points that actually grab: +// SearchBookWithOutcomes (scheduler) and SearchBookWithDebug (interactive). +func TestSeriesLabelAttributionReachesSearchEntrypoints(t *testing.T) { + const wrong = "Heart of Glass, Skye Druids (03) by Donna Grant EPUB" + const right = "Heart of Glass by Jennifer Hillier EPUB" + feed := `<?xml version="1.0" encoding="UTF-8"?> +<rss version="2.0" xmlns:newznab="http://www.newznab.com/DTD/2010/feeds/attributes/"> + <channel> + <newznab:response offset="0" total="2"/> + <item><title>` + wrong + `g0 + ` + right + `g1 + +` + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/xml") + _, _ = w.Write([]byte(feed)) + })) + defer srv.Close() + + idxs := []models.Indexer{{ID: 1, Name: "test", URL: srv.URL, Enabled: true, Categories: []int{7020}}} + crit := MatchCriteria{Title: "Heart of Glass", Author: "Jennifer Hillier"} + ctx := context.Background() + debugged, _ := newTestSearcher().SearchBookWithDebug(ctx, idxs, crit) + outcomes, _ := newTestSearcher().SearchBookWithOutcomes(ctx, idxs, crit) + for name, got := range map[string][]string{ + "SearchBookWithDebug": resultTitles(debugged), + "SearchBookWithOutcomes": resultTitles(outcomes), + } { + if slices.Contains(got, wrong) { + t.Errorf("%s kept the wrong author release %q", name, wrong) + } + if !slices.Contains(got, right) { + t.Errorf("%s dropped the right release %q (got %q)", name, right, got) + } + } +}