From 9c1beddc7b55d06578d4de8342bd36a0063d69f5 Mon Sep 17 00:00:00 2001
From: Sergio Vera
Date: Thu, 29 Oct 2026 11:41:44 +0100
Subject: [PATCH 01/50] Added biblioreads support
---
README.md | 24 +
biblioreads.json | 765 ++++++++++++++++++
cli_input.go | 17 +
cli_input_test.go | 67 ++
internal/datasource/biblioreads/cache.go | 65 ++
internal/datasource/biblioreads/client.go | 297 +++++++
.../datasource/biblioreads/client_test.go | 509 ++++++++++++
.../biblioreads/fixtures/book-scraper.json | 60 ++
.../biblioreads/fixtures/reviews-scraper.json | 672 +++++++++++++++
.../biblioreads/fixtures/search-books.json | 19 +
internal/datasource/biblioreads/model.go | 108 +++
internal/datasource/httpjson/httpjson.go | 72 ++
internal/datasource/httpjson/httpjson_test.go | 77 ++
internal/datasource/wikidata/httpclient.go | 55 +-
.../datasource/wikidata/httpclient_test.go | 22 -
internal/index/bleve.go | 39 +-
internal/index/bleve_document_read.go | 25 +-
internal/index/bleve_document_write.go | 4 +
internal/index/bleve_goodreads_write.go | 323 ++++++++
internal/index/bleve_goodreads_write_test.go | 513 ++++++++++++
internal/index/bleve_textrank_write.go | 26 +-
internal/index/document.go | 11 +
internal/index/goodreads_fields_test.go | 86 ++
internal/webserver/controller.go | 4 +-
.../controller/document/controller.go | 7 +-
.../webserver/controller/document/detail.go | 1 +
.../controller/document/goodreads.go | 69 ++
internal/webserver/document_detail_test.go | 2 +-
internal/webserver/document_goodreads_test.go | 246 ++++++
.../embedded/js/reader-reading-mode.js | 33 +
.../webserver/embedded/translations/de.yml | 2 +
.../webserver/embedded/translations/es.yml | 2 +
.../webserver/embedded/translations/fr.yml | 2 +
.../webserver/embedded/translations/ru.yml | 2 +
.../embedded/views/document/detail.html | 2 +-
.../views/partials/document-goodreads.html | 1 +
.../views/partials/document-metadata.html | 3 +
.../js-tests/reader-reading-mode.test.mjs | 208 +++++
internal/webserver/routes.go | 1 +
internal/webserver/upload_test.go | 2 +-
internal/webserver/webserver.go | 1 +
internal/webserver/webserver_test.go | 2 +-
main.go | 26 +-
43 files changed, 4373 insertions(+), 99 deletions(-)
create mode 100644 biblioreads.json
create mode 100644 cli_input_test.go
create mode 100644 internal/datasource/biblioreads/cache.go
create mode 100644 internal/datasource/biblioreads/client.go
create mode 100644 internal/datasource/biblioreads/client_test.go
create mode 100644 internal/datasource/biblioreads/fixtures/book-scraper.json
create mode 100644 internal/datasource/biblioreads/fixtures/reviews-scraper.json
create mode 100644 internal/datasource/biblioreads/fixtures/search-books.json
create mode 100644 internal/datasource/biblioreads/model.go
create mode 100644 internal/datasource/httpjson/httpjson.go
create mode 100644 internal/datasource/httpjson/httpjson_test.go
create mode 100644 internal/index/bleve_goodreads_write.go
create mode 100644 internal/index/bleve_goodreads_write_test.go
create mode 100644 internal/index/goodreads_fields_test.go
create mode 100644 internal/webserver/controller/document/goodreads.go
create mode 100644 internal/webserver/document_goodreads_test.go
create mode 100644 internal/webserver/embedded/js/reader-reading-mode.js
create mode 100644 internal/webserver/embedded/views/partials/document-goodreads.html
create mode 100644 internal/webserver/js-tests/reader-reading-mode.test.mjs
diff --git a/README.md b/README.md
index a68a9569..e9de8da3 100644
--- a/README.md
+++ b/README.md
@@ -18,6 +18,7 @@ A personal documents server, Coreander indexes the documents (EPUBs and PDFs wit
* Upload documents through the web interface.
* Download as kepub (epub for Kobo devices) converted on the fly thanks to [Kepubify](https://github.com/pgaskin/kepubify).
* Gather information about authors from [Wikidata](https://wikidata.org).
+* Show Goodreads ratings and book and author links through [BiblioReads](#biblioreads-integration).
## Installation
@@ -85,6 +86,26 @@ Refer to [Caddy documentation](https://caddyserver.com/docs) for more informatio
When using a reverse proxy, include its client-facing port in `FQDN`, for example `FQDN=coreander.example.com:443` for the HTTPS setup above (or `:80` for HTTP), so generated links do not use Coreander's internal listening port.
+### BiblioReads integration
+
+Coreander uses [BiblioReads](https://github.com/nesaku/biblioreads), an alternative Goodreads front-end, to find Goodreads book and author links and display book ratings on document detail pages. The integration is enabled by default and does not require an API key or a Goodreads account.
+
+Coreander searches using the title and first author, then retries with the title alone if no result qualifies. A result must match both the title and one of the document's authors, ignoring capitalization, accents, punctuation, and whitespace differences (including spacing between author initials). Trailing series annotations such as `(Series name, #1)` and omnibus ranges such as `(#1-#3)` are ignored when comparing titles, but arbitrary subtitles and standalone volume numbers such as `(#1)` are not removed. Exact normalized titles are preferred over titles with an added series annotation. Similar-looking titles alone are not enough: if no result passes these checks, no Goodreads link or rating is shown. Matches may still refer to a different edition of the same book.
+
+Coreander looks up documents in the background after indexing and can also look up a document when its detail page is opened. Ratings load asynchronously, without blocking the rest of the page. Previously saved lookup outcomes, including books with no match, are reused; to re-evaluate them with the current matching rules, restart Coreander with `--force-indexing` (or `FORCE_INDEXING=true`). This reindexes the whole library and repeats Goodreads lookups.
+
+By default, requests are spread across built-in public BiblioReads instances, with automatic failover when an instance is unavailable. To use your own instance or a different set of public instances, pass a comma-separated list:
+
+```sh
+coreander --biblioreads-instances=https://br.example.org,https://br2.example.org /absolute/path/to/library
+```
+
+Alternatively, set `BIBLIOREADS_INSTANCES` to the same comma-separated list. The flag may also be repeated, overrides the environment variable, and replaces the built-in instance list. Instance URLs must use HTTP or HTTPS.
+
+Successful responses are cached in memory for 24 hours by default. Change this with `--biblioreads-cache-ttl` or `BIBLIOREADS_CACHE_TTL`, in seconds; set it to `0` to disable response caching. Discovered book and author links are stored in the document index.
+
+Lookups send document titles and author names to the configured BiblioReads instances. To disable the integration, use `--disable-biblioreads` or set `DISABLE_BIBLIOREADS=true`. If no instance can serve a request, Goodreads information may be unavailable, but the rest of Coreander remains usable.
+
### Email
Some features rely on having an SMTP email service set up, and won't be available otherwise:
@@ -175,6 +196,9 @@ In case both a flag and its equivalent environment variable are passed, flag tak
|`--client-dynamic-image-cache-ttl` |`CLIENT_DYNAMIC_IMAGE_CACHE_TTL` | Client-side cache duration for dynamically generated images (covers, author images) in seconds. Defaults to 86400 (24 hours).
|`--server-static-cache-ttl` |`SERVER_STATIC_CACHE_TTL` | Server-side cache duration for static assets (CSS, JS, images) in seconds. Defaults to 31536000 (1 year).
|`--server-dynamic-image-cache-ttl` |`SERVER_DYNAMIC_IMAGE_CACHE_TTL` | Server-side cache duration for dynamically generated images (covers, author images) in seconds. Defaults to 86400 (24 hours).
+|`--disable-biblioreads` |`DISABLE_BIBLIOREADS` | Disable fetching Goodreads data (book and author links) from BiblioReads. Defaults to false.
+|`--biblioreads-instances` |`BIBLIOREADS_INSTANCES` | Comma-separated HTTP or HTTPS BiblioReads instance URLs, replacing the built-in public instances. The flag may be repeated. For example: `--biblioreads-instances=https://br.example.org,https://br2.example.org`. Uses the built-in instances when omitted.
+|`--biblioreads-cache-ttl` |`BIBLIOREADS_CACHE_TTL` | Duration in seconds for which BiblioReads responses are cached in memory. Set to 0 to disable caching. Defaults to 86400 (24 hours).
|`-f` or `--force-indexing` |`FORCE_INDEXING` | Whether to force indexing already indexed documents or not. Defaults to false.
|`-s` or `--jwt-secret` |`JWT_SECRET` | String to use to sign JWTs.
|`-a` or `--require-auth` |`REQUIRE_AUTH` | Require authentication to access the application if true. Defaults to false.
diff --git a/biblioreads.json b/biblioreads.json
new file mode 100644
index 00000000..9b9694b2
--- /dev/null
+++ b/biblioreads.json
@@ -0,0 +1,765 @@
+//POST https://biblioreads.eu.org/api/search/books
+//PAYLOAD
+//{queryURL: "https://www.goodreads.com/search?q=La%20Reina%20Drag%C3%B3n%20Alice%20Borchardt"}
+//RESPONSE
+{
+ "status": "Received",
+ "source": "https://github.com/nesaku/biblioreads",
+ "apiURL": "https://www.goodreads.com/book/auto_complete?format=json&q=La%20Reina%20Drag%C3%B3n%20Alice%20Borchardt",
+ "searchType": "books",
+ "numberOfResults": "1 results",
+ "result": [
+ {
+ "id": 1,
+ "cover": "https://i.gr-assets.com/images/S/compressed.photo.goodreads.com/books/1332599049i/12207444._SX50_.jpg",
+ "title": "La Reina Dragon (La leyenda de Guinevere, #1)",
+ "bookURL": "/book/show/12207444-la-reina-dragon",
+ "author": "Alice Borchardt",
+ "authorURL": "/author/show/151155.Alice_Borchardt",
+ "rating": "3.82"
+ }
+ ],
+ "lastScraped": "2026-10-28T17:45:31.333Z"
+}
+
+//POST https://biblioreads.eu.org/api/book-scraper
+// PAYLOAD
+// {queryURL: "https://www.goodreads.com/book/show/12207444-la-reina-dragon"}
+// RESPONSE
+{
+ "status": "Received",
+ "statusCode": 200,
+ "source": "https://github.com/nesaku/biblioreads",
+ "scrapeURL": "https://www.goodreads.com/book/show/12207444-la-reina-dragon",
+ "legacyBookID": 12207444,
+ "bookID": "kca://book/amzn1.gr.book.v1.4FDu7eHal9NDfA3jGNhOXw",
+ "resourceID": "kca://work/amzn1.gr.work.v1.dbcQjzlbvXR8RBZBdd8J6g",
+ "cover": "https://m.media-amazon.com/images/S/compressed.photo.goodreads.com/books/1332599049i/12207444.jpg",
+ "series": "Tales of Guinevere",
+ "seriesURL": "/series/50072-tales-of-guinevere",
+ "seriesPosition": "1",
+ "workURL": "https://www.goodreads.com/book/show/12207444-la-reina-dragon",
+ "title": "La Reina Dragon",
+ "author": [
+ {
+ "id": 1,
+ "name": "Alice Borchardt",
+ "role": "Author",
+ "url": "/author/show/151155.Alice_Borchardt"
+ }
+ ],
+ "rating": "3.82",
+ "ratingCount": "2,285",
+ "reviewsCount": "115",
+ "desc": "Arturo se dio la vuelta y vino resueltamente hacia nosotros. Estaba espléndido, y nunca olvidaré que en ese momento comencé a amarlo. Y ya nunca dejé de hacerlo. Nunca nadie me ha provocado tanta pena, dolor y alegría como él. Ni siquiera por mis hijos he sentido el amor que le profeso todavía ahora. Y creo que aunque hubiera sabido lo que nos deparaba el futuro (preocupaciones, tormentos, batallas y penas), aun así levaría entregado mi corazón tal como hice entonces.\r\n\r\nAlice Brondchardt entra en el reino de la leyenda artúrica y lo hace suyo. La reina Dragón es el primer volumen de la trilogía que recrea el mundo de Camelot con la reina Guinevere convertida en una guerrea sagaz y obstinada.\r\n\r\nNacida en un entorno en el que los enfrentamientos son constantes y las armas están siempre presentes en la vida de hombres y mujeres, Guinevere, hija de una poderosa reina pagana, es una amenaza para su gente y una preciada presa para el temible mago Merlín.\r\n\r\nEnviada a u lugar secreto, crece bajo la protección de un hombre-lobo y de un druida de agrio carácter. Pero ni siquiera en las remotas costas de Escocia, donde los dragones velan por ella, está a salvo del todopoderoso mago. Merlín conoce el destino de la bella joven y no se detendrá ante nada para evitar el cumplimiento de la profecía, puesto que si Guinevere y Arturo llegan a reinar, llevarán un paz al país que convertirá a Merlín, hambriento de poder, en un personaje secundario.",
+ "genres": [
+ "Fantasy",
+ "Arthurian",
+ "Fiction",
+ "Historical Fiction",
+ "Dragons",
+ "Historical",
+ "Mythology",
+ "Magic",
+ "Science Fiction Fantasy",
+ "Retellings"
+ ],
+ "bookEdition": "452 pages, Hardcover",
+ "publishDate": "Wed Jan 01 2003",
+ "publisher": "Timun Mas",
+ "language": "Spanish; Castilian",
+ "asin": "8448033183",
+ "isbn": "8448033183",
+ "isbn13": "9788448033187",
+ "related": [],
+ "reviews": [],
+ "reviewBreakdown": {
+ "rating1": "72",
+ "rating2": "170",
+ "rating3": "586",
+ "rating4": "731",
+ "rating5": "726"
+ },
+ "quotes": "4",
+ "quotesURL": "/work/quotes/2440721",
+ "questions": "1",
+ "questionsURL": "/book/258666/questions",
+ "lastScraped": "2026-10-28T18:34:27.525Z"
+}
+
+// POST https://biblioreads.eu.org/api/reviews-scraper
+// PAYLOAD
+// {resourceID: "kca://work/amzn1.gr.work.v1.dbcQjzlbvXR8RBZBdd8J6g", legacyBookID: 12207444,…}
+// RESPONSE
+{
+ "status": "Received",
+ "statusCode": 200,
+ "source": "https://github.com/nesaku/biblioreads",
+ "scrapeURL": "https://www.goodreads.com/book/show/12207444/reviews",
+ "totalCount": 115,
+ "nextPageToken": "NTgsMTcwNTAxNjc4NjQzNA",
+ "prevPageToken": "MTgwMSwxMjE0MTUxODU5MDAw",
+ "reviews": [
+ {
+ "id": "kca://review:goodreads/amzn1.gr.review:goodreads.v1.gH7Sf4_G4iz72wvI_yVVyg",
+ "reviewer": {
+ "id": 1259454,
+ "name": "Shawna Astley",
+ "url": "/user/show/1259454-shawna-astley",
+ "avatar": "https://i.gr-assets.com/images/S/compressed.photo.goodreads.com/users/1214150161i/1259454._UY200_CR33,0,200,200_.jpg",
+ "isAuthor": false,
+ "reviewCount": 42,
+ "followerCount": 0
+ },
+ "rating": 5,
+ "text": "Women fighting men with swords - whats not to like? ",
+ "createdAt": "Sun Jun 22 2008",
+ "updatedAt": "Tue Jan 07 2025",
+ "spoiler": false,
+ "likeCount": 18,
+ "commentCount": 0,
+ "recommendFor": null,
+ "shelf": "read",
+ "reviewURL": "/review/show/25118938"
+ },
+ {
+ "id": "kca://review:goodreads/amzn1.gr.review:goodreads.v1.be94OXB9ezWIlFZvdGMKvA",
+ "reviewer": {
+ "id": 2478293,
+ "name": "Zeek",
+ "url": "/user/show/2478293-zeek",
+ "avatar": "https://i.gr-assets.com/images/S/compressed.photo.goodreads.com/users/1339154824i/2478293.jpg",
+ "isAuthor": false,
+ "reviewCount": 939,
+ "followerCount": 149
+ },
+ "rating": 3,
+ "text": "Although I liked this tale overall, there were pieces of it that bogged the story down for me.
The Dragon Queen is yet another entry into the tales of Arthur and The Knights of the Round Table. For those who love their Arthurian legends with Merlin as the big bad- then this book's for you. For those who don't-then Run away!
TDQ centers around Guinevere, the warrior queen of Arthur and in Borchardt's version she is indeed a warrior. Guinevere, has the beauty of previous Arthurian tales, but in TDQ she's not the sweet little misguided star-crossed lover of Lancelot that we usually see her portrayed to be. Courageous and bold she has power in her right hand where ferocious fire emanates (grand super power that!), and healing in her left. Moreover, arrows flyout her bow as if an extension of her arm, she communicates with dragons- as well with the dead- displays prophetic abilities, and audaciously defeats asundry of evil beasts that Merlin flings at her.
The story begins with Maeniel, Guinevere's soon to be foster father. Maeniel, otherwise knows as the Gray Watcher, is a shapeshifting man/wolf who has personally felt the destructive powers of Merlin. Deciding that being a wolf was much preferred to living with the humans, he escapes to the wilds and begins a family. It is during his time with the wolves that Guinevere falls into his care, and the Gray Watcher, along with the sorcerer Dugald- who Merlin chased into hiding long ago- take up the charge of raising her. The Gray Watcher's wolf mate nurses Guinevere from the time they find her left on a cliff and later a woman joins them who will teach her the ways of her people- a people she never knew.
As a child, Guinevere helps her village to defeat a marauding group of seafarers- and it is here where we see her potential for greatness begin to come forth. Guinevere grows in stature, wisdom and power and, though still only a young teen through much of the story, she quickly finds herself thrust into peril. Merlin wants her dead, and though the author details many things in TDQ, I never quite got the gist of his motive for seeking to destroy her so passionately. The only thing I can figure is that she must be the one who either precipitates or actually carries out Merlin's eventual destruction.
Many other side characters show up to aid or engage her in battle. There are all kinds of fantasy creatures- including mythological gods- that she meets up with and, eventually, she meets her future in the boy of Arthur.
Arthur is just as strong as she- and closer to her age then what I've seen portrayed before. Set long before Camalot, he's young and only discovering who and what he's eventually going to be. Much of the middle of the book is from his POV, where he meets the Lady of the Lake. I was disappointed however, that at the end of TDQ, he and Guinevere do not meet up again. (I suppose that will come in later installments.)
The jumping around of POV's was rather confusing for me and the author lost me a bit until it began to pick up again toward the end. The book is full of detailed descriptions of the world Borchardt has created and some of it became overwhelming. To me her descriptions dragged the story down and lost me for most of the middle of the book. But, as I said before, by the end I was back with her.
This is definitely sequel bait- the story is far from over. I'm interested to see where the author- who was Anne Rice's sister, btw- will take it.",
+ "createdAt": "Thu May 20 2010",
+ "updatedAt": "Sun May 24 2026",
+ "spoiler": false,
+ "likeCount": 10,
+ "commentCount": 4,
+ "recommendFor": null,
+ "shelf": "read",
+ "reviewURL": "/review/show/103546143"
+ },
+ {
+ "id": "kca://review:goodreads/amzn1.gr.review:goodreads.v1.sC0IQDlAXY4LbkJ3LTJHvA",
+ "reviewer": {
+ "id": 80386185,
+ "name": "Rial",
+ "url": "/user/show/80386185-rial",
+ "avatar": "https://i.gr-assets.com/images/S/compressed.photo.goodreads.com/users/1545932910i/80386185._UX200_CR0,0,200,200_.jpg",
+ "isAuthor": false,
+ "reviewCount": 30,
+ "followerCount": 0
+ },
+ "rating": 1,
+ "text": "I did not like this book. However, it is my first 1 Star, so it's got that going for it.
Why did I read this? It was selected for my wife and roommate's book club, and I parasitically piggyback a lot of their books (gives me something to talk about...). It's Arthurian! It's from Guinivere's perspective! Magic! Celtic! Post 'Fall of the Western Empire'! These things and more!
No, it's terrible. Why did I keep reading it? It did take me on a journey: I experienced all the 'Stages of Grief' (some of them more than once.
Denial: Ha, it's so weird, but it'll get better! The chapters are long, but that's okay! Weird, the perspective shifted...? Anger: I liked the other character; this one is dumb. She's a Mary Sue! WTF? That just worked out for her with zero effort! Okay, she needs to show some growth... Bargaining: ...Okay, the parts with Arthur aren't bad, maybe? Anger: Why are they having revenge sex in the forest? Denial: Arthur is cool, yes, he's very cool... :| Depression: I really shouldn't be reading this anymore; I think it's actively hurting me. Acceptance: There is only book. Oh yeah, I guess your shoes can do that. Yep, you're definitely super fucking hot, and everyone wants to be or be with you. Sure why not? I don't have enough brain power to think it should be any different. After all, you're the best, Gwen!
But the real joy of this book is in not starting it. If you made that mistake and you're too masochistic to give up, then finishing it is an exultant experience
The contents are one thing, but they aren't even well served by the writing. Transitions are abrupt and jarring. I back-read multiple times to try and figure out where the hell I was, only to realize there wasn't any explanation; it just got there and it was fine (it wasn't fine).
This thing had such potential: Arthurian set in the 'proper' time frame, the Christianization of Britannia and the clashes with pagan beliefs that ensue, Celtic and Brythonic myth woven throughout. But it all reads so poorly in the end. The author is far too verbose and floral with her language. First person POV ruins any sense of actual accomplishment because we only see the character succeeding; no real growth or change is experienced. I find Divine Right to be problematic, and this book is very much into the romanticism of a destined ruler (both for Arthur and for Gwen).
Oh yeah, Merlin is evil and helps Arthur's mom torment Arthur, I guess? Like, if you hate the kid so much just kill him? Oh wait, 'destiny'...
Tedious tripe, glorified garbage, wretched rubbish. What a waste of time.",
+ "createdAt": "Mon Nov 05 2018",
+ "updatedAt": "Fri Jun 10 2022",
+ "spoiler": false,
+ "likeCount": 6,
+ "commentCount": 2,
+ "recommendFor": null,
+ "shelf": "read",
+ "reviewURL": "/review/show/2578561727"
+ },
+ {
+ "id": "kca://review:goodreads/amzn1.gr.review:goodreads.v1.rwxYpuQEynxP2f5dSLFmPQ",
+ "reviewer": {
+ "id": 90733221,
+ "name": "Monique Mihalitsianos",
+ "url": "/user/show/90733221-monique-mihalitsianos",
+ "avatar": "https://i.gr-assets.com/images/S/compressed.photo.goodreads.com/users/1773156601i/90733221._UX200_CR0,29,200,200_.jpg",
+ "isAuthor": true,
+ "reviewCount": 88,
+ "followerCount": 9
+ },
+ "rating": 3,
+ "text": "This book was splendid in the story-telling sense. The story was epic and magical and engaging. Also, this is, by far, the best Guinevere I have encountered in modern fiction so far. She was strong but not brass, brave but not stupid, powerful yet humble, compassionate but not weak, feminine yet full of agency. Perhaps what I liked the most about her was that she was in communion with animals and gods alike and knew her place in the wheel of existence. She was wise like that, but never overbearingly so. I loved her. I wanted to *be* her.
Arthur I also liked very much, and I liked that this was a tortured Arthur who had suffered child abuse at the hands of her horrible queen mother Igrane (*the* worst evil queen ever) but was still good and noble and could not lie and never feared danger. I liked his relationship with his father, Uther, the High King, which was refreshingly healthy and loving. Merlin I hated. This is a book with a bad Merlin in it, which I don't like as a trope. I much prefer my Merlins to be good and Gandalf-like, but in this case it worked to have him be so evil and act as the villain. The black magic he wrought was one of the sickest and most horrifying ones I've ever read in a story.
Alice Borchardt seems to know her history well, so much so in fact that it's almost like if she lived in this period of time (Roman - Saxon) in a past life. I, for one, appreciated all of the extraneous detail relating to the customs and mores of the time because it made the world-building that much better.
The magic system in this book was refreshingly deep and believable. So many magic systems in recent fantasy books are light and superficial, as if they had been thought up in brainstorming sessions instead of being fully inspired. Not so with Borchardt's story; the magic here seemed inspired by the author's own psyche and thus, was multilayered and poliphacetic, changeable and malleable but also essentially true to its own core.
I also liked that an existencial and historical struggle was hinted at: the convergence between paganism and Christianity, if there can be a convergence at all. It was only hinted at here but I wouldn't mind if this theme in particular were elaborated more in the second book (The Raven Warrior) because it is of particular interest to me. On some level I think it was also of particular interest to Borchardt, as well. I am a huge fan of Anne Rice's (Alice Borchardt's sister) Vampire Chronicles, and Rice definitely waxes philosophical about her own struggles with different faith systems and beliefs in her own fiction, much more so than Borchardt, who chose to focus more on the story. But I wouldn't have minded if she had expanded on this in her fiction, just like her sister did. But then again, I am of a philosophical bent and like these kinds of tangents.
Why the three-star rating, then, if I liked the book so much? Well, the story was golden, but the writing was confusing at times, especially at the beginning and end. Sometimes it was almost stream-of-consciousness like, and I got lost and confused. At other times, when a character referred to somebody else as 'she', for example, I didn't know who they were talking about, because there were several 'she's' they could have been referring to. So some more specificity in this and other similar instances would have been appreciated. Sometimes, also, the writing got too dream-like, too airy fairy and dreamy...and perhaps more concrete writing would have served the book better overall. I also suspect this book could have had tighter editing. I know for a fact Anne Rice is not a fan of people editing her writing, and I don't know if Alice Borchardt felt the same as her sister does in this matter, but I for one think good editing is absolutely crucial in any book.
Putting this aside, though, I can say that I thoroughly enjoyed this book and am looking forward to reading the second and final installment. This was meant to be a trilogy, but unfortunately, the author died from illness before she could complete the third book, so two books is all we have. I will read her other books as well, though. She's a gem of a story-teller, at least in my estimation.",
+ "createdAt": "Wed Mar 18 2020",
+ "updatedAt": "Thu Nov 30 2023",
+ "spoiler": false,
+ "likeCount": 3,
+ "commentCount": 0,
+ "recommendFor": null,
+ "shelf": "read",
+ "reviewURL": "/review/show/3208179930"
+ },
+ {
+ "id": "kca://review:goodreads/amzn1.gr.review:goodreads.v1.XvL2nHk7Bt4tULNXUE2yaA",
+ "reviewer": {
+ "id": 2207539,
+ "name": "Steen",
+ "url": "/user/show/2207539-steen",
+ "avatar": "https://i.gr-assets.com/images/S/compressed.photo.goodreads.com/users/1408254648i/2207539._UY200_CR33,0,200,200_.jpg",
+ "isAuthor": false,
+ "reviewCount": 473,
+ "followerCount": 4
+ },
+ "rating": 1,
+ "text": "This was a did not finish book for me.
So after reading about 185 pages of The Dragon Queen by Alice Borchardt I have decided to thrown it down and stop reading. I thought I would like it being about Quinevere and all but I hate it. It's not like the Arthur legends I've ever known. But that's not really even it though. I could handle a different take on the stories ( I still have yet to read any books about Arthur that are out there that are more well known) it's just the way she has written it. She jumps from one place to another and it's like when did that happen? Also sometimes when there are a few people talking 'she says...' your like who is talking? Which one is saying what. UGH. I hate not finishing a book but I really can't continue to read it anymore.
Having said that I really have no urge to read Bequiled which is also by her. I read the first in that series and while it was better written then The Dragon Queen I really don't love the characters in the first book to continue to read the second one anymore. Not after trying to get through The Dragon Queen and hating it. It's sad but yeah. I love Anne Rice's books and hoped that I would equally like her sister's books. But I guess they are just not for me.
I've read reviews on these two books and it sounds like her werewolf books are pretty good but I think I may wait a while to even attempt to try those ones.
So now its off to the library today to pick up some more to read.",
+ "createdAt": "Sat Apr 11 2009",
+ "updatedAt": "Wed Jan 23 2019",
+ "spoiler": false,
+ "likeCount": 3,
+ "commentCount": 3,
+ "recommendFor": null,
+ "shelf": "read",
+ "reviewURL": "/review/show/52264098"
+ },
+ {
+ "id": "kca://review:goodreads/amzn1.gr.review:goodreads.v1.rZ5icbmFfLHPKpEQOm2kNw",
+ "reviewer": {
+ "id": 41814516,
+ "name": "Alberto García",
+ "url": "/user/show/41814516-alberto-garc-a",
+ "avatar": "https://i.gr-assets.com/images/S/compressed.photo.goodreads.com/users/1508706260i/41814516._UX200_CR0,33,200,200_.jpg",
+ "isAuthor": true,
+ "reviewCount": 45,
+ "followerCount": 40
+ },
+ "rating": 4,
+ "text": "Voto de confianza... esta m¡¡bien escrito y la revisión de la leyenda artúrica es muy interesante. Al final, en algunos capítulos, se le va un poco la pinza con salidas algo paranoicas que me han dejado bastante descolocado... lo que es seguro es que no me lo esperaba... pero tampoco comprendo demasiado la razón para ello. Veremos cómo acaba y si se queda en una anécdota.",
+ "createdAt": "Fri Jun 21 2019",
+ "updatedAt": "Mon Sep 14 2026",
+ "spoiler": false,
+ "likeCount": 3,
+ "commentCount": 0,
+ "recommendFor": null,
+ "shelf": "read",
+ "reviewURL": "/review/show/2796875885"
+ },
+ {
+ "id": "kca://review:goodreads/amzn1.gr.review:goodreads.v1.G3-a2b8-oopoAbpH6RmbsA",
+ "reviewer": {
+ "id": 73527602,
+ "name": "Rachel",
+ "url": "/user/show/73527602-rachel",
+ "avatar": "",
+ "isAuthor": false,
+ "reviewCount": 13,
+ "followerCount": 0
+ },
+ "rating": 3,
+ "text": "This was like reading someone's dream. The ideas were all loosely connected but the transitions were weird and random or completely non-existent. However, the characters and concepts were fun and interesting and I did like this book.",
+ "createdAt": "Fri Jan 04 2019",
+ "updatedAt": "Mon Nov 17 2025",
+ "spoiler": false,
+ "likeCount": 4,
+ "commentCount": 0,
+ "recommendFor": null,
+ "shelf": "read",
+ "reviewURL": "/review/show/2582029737"
+ },
+ {
+ "id": "kca://review:goodreads/amzn1.gr.review:goodreads.v1.juoLRbi8R_urzbj6jgRAww",
+ "reviewer": {
+ "id": 101256289,
+ "name": "Malischa Nijenhuis",
+ "url": "/user/show/101256289-malischa-nijenhuis",
+ "avatar": "https://i.gr-assets.com/images/S/compressed.photo.goodreads.com/users/1773261867i/101256289._UX200_CR0,0,200,200_.jpg",
+ "isAuthor": false,
+ "reviewCount": 1222,
+ "followerCount": 32
+ },
+ "rating": 5,
+ "text": "Eigenlijks lees ik nu een boek van mijn man maar wauw wat is dit een gave uitgave en boek. Nu op naar boek 2",
+ "createdAt": "Sat Mar 21 2020",
+ "updatedAt": "Mon Mar 23 2020",
+ "spoiler": false,
+ "likeCount": 2,
+ "commentCount": 0,
+ "recommendFor": null,
+ "shelf": "read",
+ "reviewURL": "/review/show/3238259324"
+ },
+ {
+ "id": "kca://review:goodreads/amzn1.gr.review:goodreads.v1.GLjLEKHIsU-RrmRdU73hGQ",
+ "reviewer": {
+ "id": 3504881,
+ "name": "Suzanne",
+ "url": "/user/show/3504881-suzanne",
+ "avatar": "https://i.gr-assets.com/images/S/compressed.photo.goodreads.com/users/1294105241i/3504881.jpg",
+ "isAuthor": false,
+ "reviewCount": 546,
+ "followerCount": 12
+ },
+ "rating": 2,
+ "text": "I'm very sorry to say, I could not finish this book. I REALLY wanted to be in love with this story. I even purchased the 2nd book from Thrift Books soon after I started this one. I figured they would become part of my Arthurian obsession, and I would need to own them. It started out with a great concept, and I liked the differences in the legend that I hadn't read before. I wanted to know more about the different characters, but it was so choppy, from one point of view to another back and forth, I couldn't keep track of which character I was reading about.
It just dragged on and on and on and on....it was trial after trial after trial. The characters would accomplish one win and suddenly face another trial. I felt like I was reading a bad rendition of The Odyssey. I REALLY wanted to love it. But reading became a chore, and I fell into a reading slump. The book sat in one spot for a couple weeks, and I felt it hanging over my head. I finally decided enough was enough. I wanted to be eager to read again. So I took out the bookmark, and this is going on my \"couldn't finish\" list.
I will donate the set to a Free Little Library somewhere, and I hope someone else will get some pleasure out of them.",
+ "createdAt": "Sun Oct 14 2018",
+ "updatedAt": "Fri Oct 30 2020",
+ "spoiler": false,
+ "likeCount": 2,
+ "commentCount": 2,
+ "recommendFor": null,
+ "shelf": "did-not-finish",
+ "reviewURL": "/review/show/2522667985"
+ },
+ {
+ "id": "kca://review:goodreads/amzn1.gr.review:goodreads.v1.73ge1ENF_VjVNC1CSJb8iw",
+ "reviewer": {
+ "id": 67537582,
+ "name": "Kaylee",
+ "url": "/user/show/67537582-kaylee",
+ "avatar": "https://i.gr-assets.com/images/S/compressed.photo.goodreads.com/users/1627453497i/67537582._UX200_CR0,1,200,200_.jpg",
+ "isAuthor": false,
+ "reviewCount": 793,
+ "followerCount": 33
+ },
+ "rating": 1,
+ "text": "I do have to start by saying that this is the first Arthurian legend I can recall reading. I don't think that really affected my POV but...
Extremely hard to get through from the beginning. Kept hoping it would get better but nope. Not only was the switching between third and first POV annoying but it was made worse by the main character. Did not want her voice at all. It was also hard to tell what was going on/who was speaking at times (during the third POV of course). In the beginning I went back to the and re-read things but quickly learned there was no point. Sometimes things arrived randomly and others I realized I had just spaced out because of all the needless words. You could take out half the words in this book and be all the better off for it...
Also if I had to read \"As I said...\" one more time...!
I can't remember the last time I didn't finish a book and if I hadn't had to read this because of my book club I don't think I would have finished. Says everything in a nutshell.
So much more but I'll just leave it at that. Extremely glad to be done with this book!",
+ "createdAt": "Fri Nov 09 2018",
+ "updatedAt": "Fri Oct 30 2020",
+ "spoiler": false,
+ "likeCount": 2,
+ "commentCount": 2,
+ "recommendFor": null,
+ "shelf": "read",
+ "reviewURL": "/review/show/2577596943"
+ },
+ {
+ "id": "kca://review:goodreads/amzn1.gr.review:goodreads.v1.6kd8QVi2sBrNhUslyz1aUQ",
+ "reviewer": {
+ "id": 54738977,
+ "name": "TigerLily ",
+ "url": "/user/show/54738977-tigerlily",
+ "avatar": "https://i.gr-assets.com/images/S/compressed.photo.goodreads.com/users/1460164836i/54738977.jpg",
+ "isAuthor": false,
+ "reviewCount": 133,
+ "followerCount": 3
+ },
+ "rating": 5,
+ "text": "Really enjoyable, a page Turner that I didn't want to be finished. I am looking forward to reading the 2nd book.",
+ "createdAt": "Sun Jan 10 2021",
+ "updatedAt": "Sun Mar 21 2021",
+ "spoiler": false,
+ "likeCount": 2,
+ "commentCount": 0,
+ "recommendFor": null,
+ "shelf": "read",
+ "reviewURL": "/review/show/3732026445"
+ },
+ {
+ "id": "kca://review:goodreads/amzn1.gr.review:goodreads.v1.njxZJZUHk2BVK8mcTJp2aQ",
+ "reviewer": {
+ "id": 40251914,
+ "name": "Elizabeth Sanders",
+ "url": "/user/show/40251914-elizabeth-sanders",
+ "avatar": "https://i.gr-assets.com/images/S/compressed.photo.goodreads.com/users/1530132382i/40251914.jpg",
+ "isAuthor": false,
+ "reviewCount": 38,
+ "followerCount": 0
+ },
+ "rating": 4,
+ "text": "It's an interesting retelling of Guinevere and Arthur. I loved every bit of it and can't wait to read the sequel.",
+ "createdAt": "Thu Jul 04 2019",
+ "updatedAt": "Thu Jul 11 2019",
+ "spoiler": false,
+ "likeCount": 1,
+ "commentCount": 0,
+ "recommendFor": null,
+ "shelf": "read",
+ "reviewURL": "/review/show/2871843590"
+ },
+ {
+ "id": "kca://review:goodreads/amzn1.gr.review:goodreads.v1.HegnVxYZnLTsNGxJeVV2cA",
+ "reviewer": {
+ "id": 11384547,
+ "name": "Lyndsay MacDonald",
+ "url": "/user/show/11384547-lyndsay-macdonald",
+ "avatar": "https://i.gr-assets.com/images/S/compressed.photo.goodreads.com/users/1371265927i/11384547.jpg",
+ "isAuthor": false,
+ "reviewCount": 38,
+ "followerCount": 0
+ },
+ "rating": 2,
+ "text": "This book was weird. I think it might have been tolerable if the author didn't try to weave it into the Arthurian legend (ie. if she made up original characters), but as it is I just couldn't get into it. There were some parts/scenes that I did enjoy, and others that I didn't... but overall I found it very confusing, and often found myself asking \"What is happening right now?\" and \"Why?\"
The story line flip-flopped around a lot... the characters were not wholly believable (Guinevere did NOT seem like a 13 year old girl, by any stretch of the imagination).. and I found parts of the plot silly and unrelated.
I won't be reading any of the other books in this sequence.",
+ "createdAt": "Wed Jan 22 2014",
+ "updatedAt": "Fri Aug 12 2016",
+ "spoiler": false,
+ "likeCount": 1,
+ "commentCount": 0,
+ "recommendFor": null,
+ "shelf": "read",
+ "reviewURL": "/review/show/831334767"
+ },
+ {
+ "id": "kca://review:goodreads/amzn1.gr.review:goodreads.v1.oazwOZoGnu8yIQJ_48FVMw",
+ "reviewer": {
+ "id": 27373770,
+ "name": "Andrea Krivak",
+ "url": "/user/show/27373770-andrea-krivak",
+ "avatar": "",
+ "isAuthor": false,
+ "reviewCount": 26,
+ "followerCount": 1
+ },
+ "rating": 2,
+ "text": "I think you have to really like arthurian legends to read this book. I liked it in the beginning, it started telling the story of Geunivere as a young girl and the \"what if\" this was her upbringing. There were wizards, men who turned into wolves and of course, merlin and morgana. About halfway through she meets Arthur, and then the book divides into the telling of each of their lives and they each stray into their own mystic journeys. To me, it spent too much time on these mystic journeys and by the end, i was ready to be done with this whole story. Not interesting enough ending for me to read into the sequel.",
+ "createdAt": "Mon Mar 24 2014",
+ "updatedAt": "Fri Aug 12 2016",
+ "spoiler": false,
+ "likeCount": 1,
+ "commentCount": 0,
+ "recommendFor": null,
+ "shelf": "read",
+ "reviewURL": "/review/show/890822661"
+ },
+ {
+ "id": "kca://review:goodreads/amzn1.gr.review:goodreads.v1.IxTTRac3W8gbXoHFdfTu5g",
+ "reviewer": {
+ "id": 1009204,
+ "name": "Cindy",
+ "url": "/user/show/1009204-cindy",
+ "avatar": "https://i.gr-assets.com/images/S/compressed.photo.goodreads.com/users/1228465282i/1009204._UX200_CR0,28,200,200_.jpg",
+ "isAuthor": false,
+ "reviewCount": 856,
+ "followerCount": 105
+ },
+ "rating": 1,
+ "text": "Maybe some day I'll come back to this book, but I just don't think I can read this book. I find myself wondering if I'm not following something of if this book is just beyond my reading. The writing is in a way that makes it seems like, wow we want the read to think this author is just so descriptive and great. Only it becomes confusing and bored. If you aren't familiar with the story of Guinevere then there is no point in reading it because nothing is explained and you are just sitting there wondering what the heck is going on. ",
+ "createdAt": "Wed Aug 27 2008",
+ "updatedAt": "Thu Jan 25 2018",
+ "spoiler": false,
+ "likeCount": 0,
+ "commentCount": 0,
+ "recommendFor": null,
+ "shelf": "read",
+ "reviewURL": "/review/show/31374042"
+ },
+ {
+ "id": "kca://review:goodreads/amzn1.gr.review:goodreads.v1._BHlRCbTwy-DJRq5mCTQnw",
+ "reviewer": {
+ "id": 4804714,
+ "name": "Angela",
+ "url": "/user/show/4804714-angela",
+ "avatar": "https://i.gr-assets.com/images/S/compressed.photo.goodreads.com/users/1294946776i/4804714.jpg",
+ "isAuthor": false,
+ "reviewCount": 129,
+ "followerCount": 6
+ },
+ "rating": 3,
+ "text": "I enjoyed this book up until the last third and then it got way too much involved with Arthur. It didn't quite wrap up the way I had expected and I would suggest not reading it unless you want to continue on with the series. I may read the sequel at a later time but I need a break from the acid trip like sections with Arthur.",
+ "createdAt": "Thu Mar 28 2013",
+ "updatedAt": "Tue Aug 09 2016",
+ "spoiler": false,
+ "likeCount": 1,
+ "commentCount": 0,
+ "recommendFor": null,
+ "shelf": "read",
+ "reviewURL": "/review/show/572964102"
+ },
+ {
+ "id": "kca://review:goodreads/amzn1.gr.review:goodreads.v1.P4s-Ysv7_6bKQAupxDvCwA",
+ "reviewer": {
+ "id": 4018173,
+ "name": "Misty",
+ "url": "/user/show/4018173-misty",
+ "avatar": "https://i.gr-assets.com/images/S/compressed.photo.goodreads.com/users/1286250306i/4018173._UY200_CR33,0,200,200_.jpg",
+ "isAuthor": false,
+ "reviewCount": 307,
+ "followerCount": 0
+ },
+ "rating": 2,
+ "text": "I really wanted to like this book.
About halfway through, I got bored and tried to push through it, but ultimately gave up. There was so much description and not enough happening.
Also, considering this was titled the Dragon Queen, I expected more dragons. Their lack of presence was disappointing.",
+ "createdAt": "Tue Nov 26 2013",
+ "updatedAt": "Thu Jun 23 2016",
+ "spoiler": false,
+ "likeCount": 1,
+ "commentCount": 0,
+ "recommendFor": null,
+ "shelf": "could-not-finish",
+ "reviewURL": "/review/show/774467120"
+ },
+ {
+ "id": "kca://review:goodreads/amzn1.gr.review:goodreads.v1.2E0pSS7MyztIPHdoIbH4Nw",
+ "reviewer": {
+ "id": 120860,
+ "name": "Lassarina Aoibhell",
+ "url": "/user/show/120860-lassarina-aoibhell",
+ "avatar": "",
+ "isAuthor": false,
+ "reviewCount": 487,
+ "followerCount": 5
+ },
+ "rating": 1,
+ "text": "I am DNFing this book on page 84 and we're going to go on a wild ride about why.
NB: If you are a scholar of Arthuriana, history, religion, or....well, almost anything, you might want to put down your drink. I'm not responsible for the state of your monitor.
We begin with the cover copy: Daughter of a mighty pagan queen, Guinevere has become a threat to her people and a prize to the dreaded sorcerer Merlin. Sent into hiding, she grows up under the protection of a shapeshifting man-wolf and an ornery Druid. But even on the remote coast of Scotland, where dragons feed and watch over her, she is not safe. Merlin knows the young beauty's destiny--and he will stop at nothing to prevent what has been foretold...
I also need to strongly highlight one of the blurbs. Like, really strongly. \"In addition to being exceptionally well written, [The Dragon Queen] has enormous integrity: All of the stories--of Arthur's parentage, of Merlin, of the characters' daily struggles--are well grounded in the history, politics, and religions of the time.....This is a trilogy to watch.\" --San Jose Mercury News
I want you to look at that quote carefully, and then come on a ride with me.
I will acknowledge up front my own reading comprehension fail, because I had forgotten that the shapeshifter was actually in the blurb and was yelling about \"what the fuck.\". I'm still saying \"what the fuck,\" just about plenty of other things.
Let's start with: It takes until page 48 for Guinevere to show up in any real sense. 500 page book; the main character doesn't actually appear until 10% in. Don't like that. Instead we are following around Dugald, the druid, and Maeniel, the shapeshifter. He's allegedly from Gaul. We begin at Tintagel in the year 470 CE, where Merlin and some of his cronies murder Vortigen. Some kind of magical snake is involved. (Aside: absolutely no problem with Merlin as the villain, particularly in anything allegedly centering the women of this tale.) Maeniel the shifter teleports??? out of the carnage, finds himself a she-wolf who has recently given birth, and shacks up with her. They encounter Dugald and the toddler Guinevere, who is currently disguised as a boy. For some reason, although everyone complains about disliking the Romans, we re-enact the founding of Rome, with Maeniel's mate suckling little Guinevere. Why does the she-wolf agree to this? Who can say. Dugald, meanwhile, talks about the influence of St. Patrick. Yes, that one. \"We don't call ourselves [druids] any longer. Not since Patrick came to Ireland bearing Christ's message.\"
....ten pages ago we called Merlin a chief druid. Also. It's the year 470. We don't have exact dates of the saint's life, but we know it was roughly in that century. More importantly, it was the Romans who banned druidic practices, fucking 400 years earlier (Augustus had no chill.) So there is no way that Patrick's actions in Ireland are affecting the order in England. Remember that blurb I highlighted? HMMMM. ALSO WHY ARE YOU, A GUY IN CORNWALL, TALKING ABOUT PATRICK. why. what is even your deal.
Also also, in this variation of the story (?), apparently, Dugald had a philosophical disagreement with Merlin and \"lost,\" so he was expected by the rules of his order to commit suicide because he lost.
Me: I do not remember reading this in any of the books I consumed during my obsessive period about Celtic histories and cultures and archaeology. (Wiki and checking with knowledgeable friends suggests I am not alone in not remembering this.)
If nothing else, that seems like a giant fucking waste of a 20 year apprenticeship, man.
(At this point I also stopped to verify my memory that Dugald and Maeniel do not appear in other Arthurian sources. I seem to be correct. And like, yeah, we add characters to stories all the time, I'm just. why are you making me deal with men in a book about Guinevere!)
There's also a point where young Guinevere, Dugald, and Maeniel are involved in helping to fight off a ship of slavers, which conveniently results in there being a woman to join the household and teach Guinevere How To Be A Girl. I should specify that the captain of said slaver's ship was apparently named Cymry. Why is he literally named \"Welsh\"? fuck if I know.
There's also a particular kind of ambient misogyny that I find tiresome, and like, I know we're in the fifth century and the Romans were assholes, and also I am aware that Celtic cultures were not paragons of equality and virtue, but the available evidence both of Romans being bitchy about how ass-backward Celtic societies were (omg they let their women do things!!!!) and the remnants of brehon law allocating specific property to women after divorce and categorizing a type of marriage as \"she married down and gets to keep all her stuff\" [wildly paraphrased] I'm just like
what. what are you doing, ma'am
and once again I'm going to refer back to that blurb. About being well grounded in the history, politics, and religions of the time.
I do not recommend actually reading this unless you want to play a drinking game, in which case, may I suggest that the drink you use should perhaps be water?",
+ "createdAt": "Thu Apr 09 2026",
+ "updatedAt": "Thu Apr 09 2026",
+ "spoiler": false,
+ "likeCount": 0,
+ "commentCount": 0,
+ "recommendFor": null,
+ "shelf": "did-not-finish",
+ "reviewURL": "/review/show/1761732484"
+ },
+ {
+ "id": "kca://review:goodreads/amzn1.gr.review:goodreads.v1.9X0QSJx7BxHSTHSctKMkwg",
+ "reviewer": {
+ "id": 11348002,
+ "name": "Laural",
+ "url": "/user/show/11348002-laural",
+ "avatar": "https://i.gr-assets.com/images/S/compressed.photo.goodreads.com/users/1349329030i/11348002._UX200_CR0,5,200,200_.jpg",
+ "isAuthor": false,
+ "reviewCount": 36,
+ "followerCount": 5
+ },
+ "rating": 2,
+ "text": "I got about halfway through this book. Someday I'll give it another chance, or maybe try to figure out what exactly the author did wrong. A sword weilding Gweneviere? It could have been great. I thought it would be after reading the description on the back of the gorgeous cover. ",
+ "createdAt": "Thu Oct 04 2012",
+ "updatedAt": "Thu Jun 23 2016",
+ "spoiler": false,
+ "likeCount": 1,
+ "commentCount": 0,
+ "recommendFor": null,
+ "shelf": "read",
+ "reviewURL": "/review/show/427412066"
+ },
+ {
+ "id": "kca://review:goodreads/amzn1.gr.review:goodreads.v1.p0mSKsHft_BTv2vScLHOsQ",
+ "reviewer": {
+ "id": 1250008,
+ "name": "Giuila",
+ "url": "/user/show/1250008-giuila",
+ "avatar": "https://i.gr-assets.com/images/S/compressed.photo.goodreads.com/users/1244141968i/1250008._UY200_CR2,0,200,200_.jpg",
+ "isAuthor": false,
+ "reviewCount": 61,
+ "followerCount": 0
+ },
+ "rating": 0,
+ "text": "... t read",
+ "createdAt": "Thu Jul 17 2008",
+ "updatedAt": "Fri Mar 24 2017",
+ "spoiler": false,
+ "likeCount": 1,
+ "commentCount": 0,
+ "recommendFor": null,
+ "shelf": "to-read",
+ "reviewURL": "/review/show/27520982"
+ },
+ {
+ "id": "kca://review:goodreads/amzn1.gr.review:goodreads.v1.QMO0-GkHiX0UEYcsOaO7lw",
+ "reviewer": {
+ "id": 1431136,
+ "name": "Matt",
+ "url": "/user/show/1431136-matt",
+ "avatar": "https://i.gr-assets.com/images/S/compressed.photo.goodreads.com/users/1218856549i/1431136._UY200_CR25,0,200,200_.jpg",
+ "isAuthor": false,
+ "reviewCount": 6,
+ "followerCount": 0
+ },
+ "rating": 5,
+ "text": "an overall awesome book",
+ "createdAt": "Sat Aug 16 2008",
+ "updatedAt": "Fri Mar 24 2017",
+ "spoiler": false,
+ "likeCount": 1,
+ "commentCount": 0,
+ "recommendFor": null,
+ "shelf": "read",
+ "reviewURL": "/review/show/30279062"
+ },
+ {
+ "id": "kca://review:goodreads/amzn1.gr.review:goodreads.v1.qOdgrbVKU-XMQR5DSbHfDQ",
+ "reviewer": {
+ "id": 16979552,
+ "name": "Sarah Garner",
+ "url": "/user/show/16979552-sarah-garner",
+ "avatar": "https://i.gr-assets.com/images/S/compressed.photo.goodreads.com/users/1408284741i/16979552._UX200_CR0,53,200,200_.jpg",
+ "isAuthor": false,
+ "reviewCount": 84,
+ "followerCount": 18
+ },
+ "rating": 2,
+ "text": "I struggled with this book and I couldn't finish it, although it was a great idea, I didn't love this book.",
+ "createdAt": "Sat Sep 20 2014",
+ "updatedAt": "Thu Aug 20 2020",
+ "spoiler": false,
+ "likeCount": 1,
+ "commentCount": 0,
+ "recommendFor": null,
+ "shelf": "read",
+ "reviewURL": "/review/show/1059961768"
+ },
+ {
+ "id": "kca://review:goodreads/amzn1.gr.review:goodreads.v1.JjlmKD1eCF28HlpY4KmCGw",
+ "reviewer": {
+ "id": 34744007,
+ "name": "Michael D.",
+ "url": "/user/show/34744007-michael-d",
+ "avatar": "",
+ "isAuthor": false,
+ "reviewCount": 81,
+ "followerCount": 1
+ },
+ "rating": 2,
+ "text": "I had this book about 20 years ago, tried to read it, but I just couldn't get into it. I got another copy used and gave it another try. While not as bad as my first experience in the first 20 pages, I found in the ensuing pages that the narrative is quite disjointed, and timelines are fluid and unclear to me. Perhaps that was Borchardt's intent, but I was confused by it. Additionally, it seems that characters are magically transported to strange places without any transition that I could discern. This narrative was hard to follow.
The book begins in a scene in 470 in Tintigal (not Tintagil, the famous castle in Cornwall where Arthur was conceived and born in the traditional romance). Ostensibly, the main character is a wolfman (a wolf who changes into a man when he chooses to) named Maeniel, and he is a friend to the British king Vortigen, who is not the villain he is normally portrayed as in history and the romances. Merlin shows up with a contingent of Saxons, and a battle ensues in which Vortigen is slain and Maeniel is knocked unconscious by an unknown cudgel. In the next scene, he wakes up on a hillside far away, presumably the far north, near Hadrian's Wall, and he becomes the mate of a she-wolf, who bears him a son, Black Leg (who will become Lancelot in the second novel The Raven Warrior). Sometime later, we find that 70 years have passed since Vortigen's death. Oddly, there is a baby girl in the nearby village, Guenevere (Borchardt spells her name differently depending on who is addressing her), who is one of the major characters of this series, the eponymous destined Dragon Queen. When Guenevere is 15, we are then told that 100 years have passed since Vortigen died. Confusion abounds.
This story had occasional high points, with modern fantastical treatments of the transformation duels, but I really had a hard time following the flow of Borchardt's narrative. I am a pretty astute reader, so I find it difficult to believe this was my fault. Borchardt seemed to be playing coy with the unfolding of events, and that was annoying. She was the sister of the better-known Anne Rice, author of the Vampire Chronicles, so I tried hard to give this a chance.
Borchardt intended to make this a trilogy, but the passed away in 2007 before the third book in the trilogy could be published. I don't know if anyone picked up her notes with the intent to complete her work, and I can't imagine that there was a demand for it. I do intend to slog through The Raven Warrior, as it is my rule to read books through and evaluate them honestly. If they're bad, I point out why. The lack of comprehensible transitions in time and space are my primary complaint with this novel. I hope that The Raven Warrior is more cognizant of walking me through how characters get from Point A to Point B than this one was. ",
+ "createdAt": "Thu Nov 09 2023",
+ "updatedAt": "Thu Nov 09 2023",
+ "spoiler": false,
+ "likeCount": 0,
+ "commentCount": 0,
+ "recommendFor": null,
+ "shelf": "read",
+ "reviewURL": "/review/show/5944805120"
+ },
+ {
+ "id": "kca://review:goodreads/amzn1.gr.review:goodreads.v2.2025072400.a7efbaf3-dd61-4567-b4e1-d3f344fabb0c",
+ "reviewer": {
+ "id": 42506425,
+ "name": "Michelle M",
+ "url": "/user/show/42506425-michelle-m",
+ "avatar": "https://i.gr-assets.com/images/S/compressed.photo.goodreads.com/users/1715615833i/42506425._UX200_CR0,0,200,200_.jpg",
+ "isAuthor": false,
+ "reviewCount": 380,
+ "followerCount": 3
+ },
+ "rating": 5,
+ "text": "Honestly? 5/6 or 4.75/6 I really loved this book! It's hard to describe what exactly I loved about it though lol. It had a very ethereal quality to it... The magic in this world is just a fact of life, some peple have it, while others don't, and yet, it didn't feel fantastical. Everyone knows about magic and most peoples still believe in the gods and things even though \"The Church\" (christianity) is present on the British Isles.
Borchardt grounds the story in the time right after Rome leaves the Isles, though they are still very much an influence on the peoples. It was a very mystical vibe to the whole story, kind of like there is a shroud of fog that is the magic of the world. IDK, like I said, hard to describe lol.
I loved Guinevere, and I liked how different characters referred to her by various spellings/pronouciations thoughout the story, as if they were speaking different languages. It is a very different take on the Arthur/Guinevere story, and very much an action oriented one, with Guinevere being the focus of the action and not just a passive bystander.
I though the portrayal of the other major characters interesting, and they were given roles that they are not usual given in the story. Arthur is \"good\", Merlin and Ingrane are the \"evil\" ones, Guinevere is strong willed and knows what she wants and sets out to get it. She doesn't seem like the type of person who will betray Arthur with Lancelot, but we'll just have to find out! (I hope she doesn't). The portrayal of these characters (and other named characters) made me do some research to try to get a better picture of if they had ever been portayed like this before. My search was unsucessful lol, as they were very diffefrent from \"traditional\" stories, but I haven't really read many retellings, so I cant be sure if they are exclusive to this story.
My only compaint with this book is that I found out it was supposed to be a trilogy!!!! And YET there are only two books! T.T *cries* I know the author can't help that she passed away, but book 2 came out in 2003 and she passed in 2007! That seems like a decent amount of time to finish the third book (I'm being very ungrateful, I know I am, I just lament what never was). Also apparently Anne Rice of vampire fame is Borchardt's sister?!?!?! She doesn't have like an early draft of her sister's just laying around??? T.T
I will def be reading the next one! (this one took me so long because I paused to participate in a read a thon and then got distracted by other books lol and then I got busy with work so I didn't have as much time to read)",
+ "createdAt": "Thu Jul 24 2025",
+ "updatedAt": "Thu Jul 24 2025",
+ "spoiler": false,
+ "likeCount": 0,
+ "commentCount": 0,
+ "recommendFor": null,
+ "shelf": "read",
+ "reviewURL": "/review/show/7714101862"
+ },
+ {
+ "id": "kca://review:goodreads/amzn1.gr.review:goodreads.v1.HXGbQg0020aQJjD9G6pW2A",
+ "reviewer": {
+ "id": 163241100,
+ "name": "Alex",
+ "url": "/user/show/163241100-alex",
+ "avatar": "https://i.gr-assets.com/images/S/compressed.photo.goodreads.com/users/1763011908i/163241100._UY200_CR11,0,200,200_.jpg",
+ "isAuthor": false,
+ "reviewCount": 98,
+ "followerCount": 0
+ },
+ "rating": 2,
+ "text": "I enjoyed Guinevere’s character early on in the book. It was interesting watching her being raised alongside her foster brother, Black Leg, by a wolf shifter (Maniel) and a she-wolf. I do appreciate her relationship with the She Wolf, who Guinevere calls Mother. The thing that really kept me going through this book was Guinevere’s interactions with the dragons of the land and her connection to the land. I was also intrigued by some of the same conversations about religion in this book since Guinevere is raised in nature and has regular interactions with a Druid and some interactions with Christiany. All this being said I struggled with the writing. There are huge chunks of it that are confusing since it’s not always clear who is being talked about when he, she or they are mentioned considering there might be multiple people mentioned previously in the scene. Transitions between moments are also rough. I was questioning when things changed and what was being talked about. The long descriptions of things really didn’t help either and me zoning out as I read. It was also a bit rough to read this because of how things were worded/certain sciences being really uncomfortable for seemingly no reason out of nowhere. The dynamics between Igraine and Merlin’s relationship being one them. Scenes with them were sexual with zero warning and it wasn’t just limited to them. It happened a lot with various characters. Things were also made pretty uncomfortable with minors even if it was a scene that wasn’t intended to uncomfortably sexual, it was made that way by directly saying things like they “were far too young for even a dream of passion.” With how confusing the writing was I couldn’t even tell if there was a reason for why these sort of scenes were written in detail outside of making the audience feel uncomfortable and actively dislike the characters who are at fault for these behaviors.",
+ "createdAt": "Sun May 24 2026",
+ "updatedAt": "Sun May 24 2026",
+ "spoiler": false,
+ "likeCount": 0,
+ "commentCount": 0,
+ "recommendFor": null,
+ "shelf": "read",
+ "reviewURL": "/review/show/5900548383"
+ },
+ {
+ "id": "kca://review:goodreads/amzn1.gr.review:goodreads.v1.VoNZbtbhrTyL1Fhg43jpGw",
+ "reviewer": {
+ "id": 42064438,
+ "name": "Briana",
+ "url": "/user/show/42064438-briana",
+ "avatar": "https://i.gr-assets.com/images/S/compressed.photo.goodreads.com/users/1715717411i/42064438._UY200_CR5,0,200,200_.jpg",
+ "isAuthor": false,
+ "reviewCount": 161,
+ "followerCount": 1
+ },
+ "rating": 2,
+ "text": "This book was not for me. I had high hopes, the summary sounded interesting. It took me much longer than usual to finish this book as I struggled to enjoy it and I will not be picking up the second installment. I almost DNF'd this book several times but decided to stick with it.
The first half of the book felt like it was the author's freewriting development of the book, which she never bothered to go back and actually edit into a cohesive and understandable story. It was like being is some kind of dream where everything was hazy and only loosely connected. Sometimes you ended up in an entirely different location or time without even realizing it. It was also almost impossible to understand who was being discussed as the author tends to avoid using names and simply use pronouns leaving the reader to try and figure out (usually without success) who the \"she\" is that is being discussed. It was also very interesting that the author (or editor) chose to spell Guinevere's name differently throughout the book.
The second half was a little better and is the only reason I didn't give this book a one star. I don't mind Merlin as the antagonist as some other reviewers do and I found his character interesting. However, I did not really like the relationship between Arthur and Guinevere. I'm not sure how I am supposed to be convinced that this was some fated love story when they met for all of maybe an hour total. It seemed more insta-lust than anything else and there was no relationship development at all after that.
Overall I spent my time either being very confused and a little bored or kind of confused and mildly interested.",
+ "createdAt": "Thu Aug 15 2024",
+ "updatedAt": "Thu Aug 15 2024",
+ "spoiler": false,
+ "likeCount": 0,
+ "commentCount": 0,
+ "recommendFor": null,
+ "shelf": "read",
+ "reviewURL": "/review/show/6728900786"
+ },
+ {
+ "id": "kca://review:goodreads/amzn1.gr.review:goodreads.v1.IoZW54HX8Qad9w5IgbQB9A",
+ "reviewer": {
+ "id": 6424389,
+ "name": "Joy Pouros",
+ "url": "/user/show/6424389-joy-pouros",
+ "avatar": "https://i.gr-assets.com/images/S/compressed.photo.goodreads.com/users/1567889312i/6424389._UY200_CR47,0,200,200_.jpg",
+ "isAuthor": false,
+ "reviewCount": 125,
+ "followerCount": 2
+ },
+ "rating": 3,
+ "text": "2.5 stars.
I REALLY like the character of Guinevere, but there were other parts of the story I didn't enjoy.
The writing style can be very winding and even abstract in some ways. There are whole parts you can skim without missing much. There aren't a ton of people, but enough to get them confused, especially a couple with similar names.
I'm always very frustrated with stories that try to have strong female characters but still have an overly sexually charged world. There are so many wonderfully creative parts of this world and then we default back to several tired tropes. There's just no need.
Some chapters are 30 pages, some are 5. It doesn't appear based on POV or time, so why is this so inconsistent?
And finally... I understand this is the first book of the trilogy, but this book just felt like a long prologue. Other series the first book is still a worthwhile read and entertaining story. This is more of an origin story before you fully appreciate the main story. So there are very mixed feelings about continuing the series because while I love Guinevere, there's an awful lot to overlook. ",
+ "createdAt": "Tue Aug 30 2022",
+ "updatedAt": "Tue Aug 30 2022",
+ "spoiler": false,
+ "likeCount": 0,
+ "commentCount": 0,
+ "recommendFor": null,
+ "shelf": "read",
+ "reviewURL": "/review/show/4955253716"
+ },
+ {
+ "id": "kca://review:goodreads/amzn1.gr.review:goodreads.v1.ahVbGwMWksFfbjRS9c6Vbg",
+ "reviewer": {
+ "id": 29405146,
+ "name": "Anita",
+ "url": "/user/show/29405146-anita",
+ "avatar": "https://i.gr-assets.com/images/S/compressed.photo.goodreads.com/users/1407269712i/29405146._CR0,33,200,200_.jpg",
+ "isAuthor": false,
+ "reviewCount": 614,
+ "followerCount": 3
+ },
+ "rating": 1,
+ "text": "First of all, I love Arthurian legends. Second, I always finish books, constantly hopeful that the story will improve, but I must confess that I skimmed this book to the end. I just cannot read any more! This story has no cohesion, no main narrator and skips from one location to another mid-chapter. Plus there are men suddenly shapeshifting into wolves, and back again; no reasons given; no context for the intrigue and killings taking place; and a young girl child, supposedly Guinevere of Arthurian legend, being suckled by a she-wolf after being kidnapped. Imaginative description is great in a fantasy story; confusion is not. This book is one long puzzle - a maze with apparently no direction, interspersed with some flowery descriptions of sunsets and misty mornings; visions of ravens and dragons (fishing); and a cast of hundreds of unrelated and irrelevant characters. Reading this book feels akin to experiencing one of those dreams in which you are lost and desperately trying to find your way back home. Need I add that I did not enjoy this book, and will not be reading the sequel?",
+ "createdAt": "Fri Oct 30 2020",
+ "updatedAt": "Fri Oct 30 2020",
+ "spoiler": true,
+ "likeCount": 0,
+ "commentCount": 0,
+ "recommendFor": null,
+ "shelf": "read",
+ "reviewURL": "/review/show/3618732305"
+ },
+ {
+ "id": "kca://review:goodreads/amzn1.gr.review:goodreads.v1.zo7-0bYwOUS0LlPXyqeuXQ",
+ "reviewer": {
+ "id": 3198878,
+ "name": "Stevie",
+ "url": "/user/show/3198878-stevie",
+ "avatar": "https://i.gr-assets.com/images/S/compressed.photo.goodreads.com/users/1560478233i/3198878._UX200_CR0,33,200,200_.jpg",
+ "isAuthor": false,
+ "reviewCount": 71,
+ "followerCount": 0
+ },
+ "rating": 2,
+ "text": "Rating this book was a challenge. It was an impulse grab. I suspected it would be wonderfully terrible or terribly wonderful and there was only one way to find out.
What I liked: Arthurian legend from Guin's point of view. Cool. That awkward time period in history when they were both Christian but still observed many pagan rites. It had all the names I knew, and some of the tropes I knew: Vortigen, the witch in the well, so on and so forth from various movies I've seen.
What I disliked: the writing. Some of the dialogue wasn't needed. The descriptions could be too much in places and entirely lacking in others. Who is speaking? What do they look like? They were a human in a tower and now they are a fish? When? How? Transitions don't exist. This makes it hard to follow.
It was a wild ride, and I hung on until the end because I was curious as to how it would turn out. Turns out there's a sequel...I don't know that I have that in me...",
+ "createdAt": "Sat Mar 21 2020",
+ "updatedAt": "Sat Mar 21 2020",
+ "spoiler": false,
+ "likeCount": 0,
+ "commentCount": 0,
+ "recommendFor": null,
+ "shelf": "read",
+ "reviewURL": "/review/show/3240571912"
+ },
+ {
+ "id": "kca://review:goodreads/amzn1.gr.review:goodreads.v1.E7XotAu3DvsavlUw1u62eA",
+ "reviewer": {
+ "id": 107938758,
+ "name": "Danai",
+ "url": "/user/show/107938758-danai",
+ "avatar": "https://i.gr-assets.com/images/S/compressed.photo.goodreads.com/users/1705239736i/107938758._UX200_CR0,33,200,200_.jpg",
+ "isAuthor": false,
+ "reviewCount": 133,
+ "followerCount": 0
+ },
+ "rating": 3,
+ "text": "I didn't love it, nor did I hate it.
Everything was magical, mystical, and mysterious; Borchardt painted an imaginative world with interesting and diverse characters. She evidently has a deep knowledge of the British Isles, its history, and its lore.
But the writing was, at times, completely chaotic and confusing. As if someone was just spilling their string of thoughts without sufficient context or details - or too many of the wrong details. More than once, I found myself confused by abrupt changes in the situation, only to learn - through narration - that a memory was being shared.
This book didn't capture me nor engross me by any means, but it did beckon me to come back and finish it. If I come across the next book in the series, I will read it simply out of curiosity - what happens next for our heroes and villains? But I won't go hunting for a copy of it anytime soon...",
+ "createdAt": "Thu Jan 11 2024",
+ "updatedAt": "Thu Jan 11 2024",
+ "spoiler": false,
+ "likeCount": 0,
+ "commentCount": 0,
+ "recommendFor": null,
+ "shelf": "read",
+ "reviewURL": "/review/show/5638508977"
+ }
+ ],
+ "lastScraped": "2026-10-28T18:38:19.808Z"
+}
\ No newline at end of file
diff --git a/cli_input.go b/cli_input.go
index f707b116..1f0ed76f 100644
--- a/cli_input.go
+++ b/cli_input.go
@@ -2,6 +2,7 @@ package main
import (
"fmt"
+ "net/url"
"github.com/alecthomas/kong"
)
@@ -82,6 +83,11 @@ type CLIInput struct {
ServerStaticCacheTTL int `env:"SERVER_STATIC_CACHE_TTL" default:"31536000" name:"server-static-cache-ttl" help:"Server-side cache duration for static assets (CSS, JS, images) in seconds. Defaults to 1 year (31536000 seconds)."`
// ServerDynamicImageCacheTTL defines the server-side cache duration for dynamically generated images (covers, author images) in seconds. Defaults to 24 hours.
ServerDynamicImageCacheTTL int `env:"SERVER_DYNAMIC_IMAGE_CACHE_TTL" default:"86400" name:"server-dynamic-image-cache-ttl" help:"Server-side cache duration for dynamically generated images (covers, author images) in seconds. Defaults to 24 hours (86400 seconds)."`
+ // DisableBiblioreads turns off fetching Goodreads data from BiblioReads.
+ DisableBiblioreads bool `env:"DISABLE_BIBLIOREADS" default:"false" name:"disable-biblioreads" help:"Disable fetching Goodreads data (book and author links) from BiblioReads."`
+ BiblioreadsInstances []string `env:"BIBLIOREADS_INSTANCES" name:"biblioreads-instances" sep:"," help:"Comma-separated BiblioReads instance URLs. Overrides the built-in public instances. May be repeated."`
+ // BiblioreadsCacheTTL defines how long responses from BiblioReads are cached in memory, in seconds. Defaults to 24 hours.
+ BiblioreadsCacheTTL int `env:"BIBLIOREADS_CACHE_TTL" default:"86400" name:"biblioreads-cache-ttl" help:"Duration in seconds for which BiblioReads responses are cached in memory. Set to 0 to disable caching. Defaults to 24 hours (86400 seconds)."`
// ShareCommentMaxSize defines the maximum length for share comments in characters. Defaults to 280.
ShareCommentMaxSize int `env:"SHARE_COMMENT_MAX_SIZE" short:"m" default:"280" name:"share-comment-max-size" help:"Maximum length for share comments in characters. Defaults to 280."`
// ShareMaxRecipients defines the maximum number of recipients allowed when sharing a document. Defaults to 10.
@@ -120,6 +126,17 @@ func (c CLIInput) Validate() error {
if c.ShareCommentMaxSize < 0 {
return fmt.Errorf("share-comment-max-size must not be negative, got %v", c.ShareCommentMaxSize)
}
+ // BiblioreadsCacheTTL must not be negative: 0 is the documented way to
+ // disable caching, so a negative value is most likely a typo.
+ if c.BiblioreadsCacheTTL < 0 {
+ return fmt.Errorf("biblioreads-cache-ttl must not be negative, got %v", c.BiblioreadsCacheTTL)
+ }
+ for _, instance := range c.BiblioreadsInstances {
+ u, err := url.Parse(instance)
+ if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Hostname() == "" {
+ return fmt.Errorf("biblioreads-instances must contain absolute HTTP or HTTPS URLs, got %q", instance)
+ }
+ }
// MinPasswordLength must be positive: 0 or negative disables the minimum
// length check entirely, silently allowing empty passwords.
if c.MinPasswordLength <= 0 {
diff --git a/cli_input_test.go b/cli_input_test.go
new file mode 100644
index 00000000..0073ec69
--- /dev/null
+++ b/cli_input_test.go
@@ -0,0 +1,67 @@
+package main
+
+import (
+ "reflect"
+ "strings"
+ "testing"
+
+ "github.com/alecthomas/kong"
+)
+
+func TestBiblioreadsInstances(t *testing.T) {
+ tests := []struct {
+ name string
+ args []string
+ env string
+ want []string
+ }{
+ {name: "defaults"},
+ {name: "single", args: []string{"--biblioreads-instances=https://br.example.org"}, want: []string{"https://br.example.org"}},
+ {name: "comma separated", args: []string{"--biblioreads-instances=https://br.example.org,http://localhost:8080/"}, want: []string{"https://br.example.org", "http://localhost:8080/"}},
+ {name: "repeated", args: []string{"--biblioreads-instances=https://br.example.org", "--biblioreads-instances=https://br2.example.org"}, want: []string{"https://br.example.org", "https://br2.example.org"}},
+ {name: "environment", env: "https://br.example.org,https://br2.example.org", want: []string{"https://br.example.org", "https://br2.example.org"}},
+ {name: "flag overrides environment", env: "https://env.example.org", args: []string{"--biblioreads-instances=https://cli.example.org"}, want: []string{"https://cli.example.org"}},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Setenv("BIBLIOREADS_INSTANCES", tt.env)
+ var input CLIInput
+ parser := newCLIParser(t, &input)
+ if _, err := parser.Parse(append(tt.args, t.TempDir())); err != nil {
+ t.Fatal(err)
+ }
+ if !reflect.DeepEqual(input.BiblioreadsInstances, tt.want) {
+ t.Errorf("instances = %v, want %v", input.BiblioreadsInstances, tt.want)
+ }
+ })
+ }
+}
+
+func TestBiblioreadsInstancesRejectInvalidURLs(t *testing.T) {
+ for _, instance := range []string{"br.example.org", "/relative", "ftp://br.example.org", "https://", "https://bad host", "https://br.example.org,invalid"} {
+ t.Run(instance, func(t *testing.T) {
+ var input CLIInput
+ parser := newCLIParser(t, &input)
+ _, err := parser.Parse([]string{"--biblioreads-instances=" + instance, t.TempDir()})
+ if err == nil || !strings.Contains(err.Error(), "biblioreads-instances") {
+ t.Fatalf("expected biblioreads-instances validation error, got %v", err)
+ }
+ })
+ }
+}
+
+func newCLIParser(t *testing.T, input *CLIInput) *kong.Kong {
+ t.Helper()
+ parser, err := kong.New(input, kong.Vars{
+ "defaultMaxSimilarityCandidates": "0",
+ "defaultMinSimilarityScoreRatio": "0",
+ "defaultMaxSimilarityPhrases": "0",
+ "defaultCommonTextRankEntryRatio": "0",
+ "defaultMinCommonTextRankAbsoluteCount": "0",
+ "defaultPruneChangeTriggerRatio": "0",
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ return parser
+}
diff --git a/internal/datasource/biblioreads/cache.go b/internal/datasource/biblioreads/cache.go
new file mode 100644
index 00000000..d4427df0
--- /dev/null
+++ b/internal/datasource/biblioreads/cache.go
@@ -0,0 +1,65 @@
+package biblioreads
+
+import (
+ "sync"
+ "time"
+)
+
+// DefaultCacheTTL is how long successful responses are cached by default.
+const DefaultCacheTTL = 24 * time.Hour
+
+// cacheSweepInterval sets how often expired entries are purged, so entries that are never
+// requested again don't stay in memory forever.
+const cacheSweepInterval = 10 * time.Minute
+
+type cacheEntry struct {
+ body []byte
+ expiresAt time.Time
+}
+
+// responseCache is an in-memory cache of raw response bodies with a fixed TTL.
+type responseCache struct {
+ ttl time.Duration
+ mu sync.Mutex
+ entries map[string]cacheEntry
+ lastSweep time.Time
+}
+
+func newResponseCache(ttl time.Duration) *responseCache {
+ return &responseCache{
+ ttl: ttl,
+ entries: make(map[string]cacheEntry),
+ lastSweep: time.Now(),
+ }
+}
+
+func (c *responseCache) get(key string) ([]byte, bool) {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+
+ entry, ok := c.entries[key]
+ if !ok {
+ return nil, false
+ }
+ if !time.Now().Before(entry.expiresAt) {
+ delete(c.entries, key)
+ return nil, false
+ }
+ return entry.body, true
+}
+
+func (c *responseCache) set(key string, body []byte) {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+
+ now := time.Now()
+ if now.Sub(c.lastSweep) >= cacheSweepInterval {
+ for k, entry := range c.entries {
+ if !now.Before(entry.expiresAt) {
+ delete(c.entries, k)
+ }
+ }
+ c.lastSweep = now
+ }
+ c.entries[key] = cacheEntry{body: body, expiresAt: now.Add(c.ttl)}
+}
diff --git a/internal/datasource/biblioreads/client.go b/internal/datasource/biblioreads/client.go
new file mode 100644
index 00000000..efc39c54
--- /dev/null
+++ b/internal/datasource/biblioreads/client.go
@@ -0,0 +1,297 @@
+// Package biblioreads implements a client for the BiblioReads API
+// (https://github.com/nesaku/biblioreads), an alternative Goodreads front-end.
+package biblioreads
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "math/rand/v2"
+ "net/http"
+ "net/url"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/svera/coreander/v5/internal/datasource/httpjson"
+)
+
+const (
+ goodreadsBaseURL = "https://www.goodreads.com"
+
+ searchBooksPath = "/api/search/books"
+ bookScraperPath = "/api/book-scraper"
+ reviewsScraperPath = "/api/reviews-scraper"
+)
+
+// DefaultInstances lists public BiblioReads instances.
+var DefaultInstances = []string{
+ "https://biblioreads.eu.org",
+ "https://biblioreads.mooo.com",
+ "https://biblioreads.lunar.icu",
+ "https://biblioreads.ducks.party",
+ "https://biblioreads.privacyredirect.com",
+ "https://br.bloat.cat",
+ "https://biblioreads.utilibre.org",
+}
+
+// ErrUnavailable is returned when no instance could serve a request (network errors, rate limits,
+// non-JSON responses), as opposed to an instance answering with an error of its own.
+var ErrUnavailable = errors.New("biblioreads: all instances failed")
+
+type Client struct {
+ instances []string
+ httpClient *http.Client
+ cache *responseCache
+
+ mu sync.Mutex
+ cooldownUntil map[string]time.Time
+}
+
+type options struct {
+ instances []string
+ httpClient *http.Client
+ cacheTTL time.Duration
+}
+
+type Option func(*options)
+
+// WithInstances sets the BiblioReads instances to use. An empty list keeps DefaultInstances.
+func WithInstances(instances []string) Option {
+ return func(o *options) {
+ if len(instances) > 0 {
+ o.instances = instances
+ }
+ }
+}
+
+// WithHTTPClient sets the HTTP client used for requests. A nil client keeps the default one,
+// which has a 15 seconds timeout.
+func WithHTTPClient(httpClient *http.Client) Option {
+ return func(o *options) {
+ if httpClient != nil {
+ o.httpClient = httpClient
+ }
+ }
+}
+
+// WithCacheTTL sets how long successful responses are cached. A zero or negative ttl disables caching.
+func WithCacheTTL(ttl time.Duration) Option {
+ return func(o *options) {
+ o.cacheTTL = ttl
+ }
+}
+
+// NewClient returns a client that spreads requests across instances. Each request starts at a
+// random instance and fails over to the next ones if it is unavailable (network error, 429 or a
+// non-JSON response). Unavailable instances are skipped until their Retry-After time, or
+// httpjson.DefaultRetryAfter, has passed. Successful responses are cached in memory for
+// DefaultCacheTTL unless changed with WithCacheTTL.
+func NewClient(opts ...Option) *Client {
+ o := options{
+ instances: DefaultInstances,
+ httpClient: &http.Client{Timeout: 15 * time.Second},
+ cacheTTL: DefaultCacheTTL,
+ }
+ for _, opt := range opts {
+ opt(&o)
+ }
+
+ normalized := make([]string, len(o.instances))
+ for i, instance := range o.instances {
+ normalized[i] = strings.TrimRight(instance, "/")
+ }
+
+ c := &Client{
+ instances: normalized,
+ httpClient: o.httpClient,
+ cooldownUntil: make(map[string]time.Time),
+ }
+ if o.cacheTTL > 0 {
+ c.cache = newResponseCache(o.cacheTTL)
+ }
+ return c
+}
+
+type queryURLPayload struct {
+ QueryURL string `json:"queryURL"`
+}
+
+type reviewsPayload struct {
+ ResourceID string `json:"resourceID"`
+ LegacyBookID int `json:"legacyBookID"`
+ NextPageToken string `json:"nextPageToken,omitempty"`
+}
+
+// SearchBooks searches Goodreads books matching query.
+func (c *Client) SearchBooks(ctx context.Context, query string) (SearchBooksResponse, error) {
+ var res SearchBooksResponse
+ payload := queryURLPayload{
+ QueryURL: goodreadsBaseURL + "/search?q=" + escapeQuery(query),
+ }
+ err := c.post(ctx, searchBooksPath, payload, &res)
+ return res, err
+}
+
+// Book returns the details of the Goodreads book identified by id, which can be
+// either a numeric ID ("12207444") or an ID with slug ("12207444-la-reina-dragon").
+func (c *Client) Book(ctx context.Context, id string) (BookResponse, error) {
+ var res BookResponse
+ payload := queryURLPayload{
+ QueryURL: goodreadsBaseURL + "/book/show/" + url.PathEscape(id),
+ }
+ err := c.post(ctx, bookScraperPath, payload, &res)
+ return res, err
+}
+
+// Reviews returns a page of reviews for the work identified by resourceID and legacyBookID
+// (both available in BookResponse). Pass an empty nextPageToken to get the first page.
+func (c *Client) Reviews(ctx context.Context, resourceID string, legacyBookID int, nextPageToken string) (ReviewsResponse, error) {
+ var res ReviewsResponse
+ payload := reviewsPayload{
+ ResourceID: resourceID,
+ LegacyBookID: legacyBookID,
+ NextPageToken: nextPageToken,
+ }
+ err := c.post(ctx, reviewsScraperPath, payload, &res)
+ return res, err
+}
+
+// unavailableError means the instance can't serve requests right now and another one should be tried.
+type unavailableError struct {
+ endpoint string
+ retryAfter time.Duration
+ reason error
+}
+
+func (e *unavailableError) Error() string {
+ return fmt.Sprintf("%s: %v", e.endpoint, e.reason)
+}
+
+func (e *unavailableError) Unwrap() error {
+ return e.reason
+}
+
+func (c *Client) post(ctx context.Context, path string, payload, dest any) error {
+ body, err := json.Marshal(payload)
+ if err != nil {
+ return err
+ }
+
+ cacheKey := path + "\x00" + string(body)
+ if c.cache != nil {
+ if cached, ok := c.cache.get(cacheKey); ok {
+ return json.Unmarshal(cached, dest)
+ }
+ }
+
+ var errs []error
+ for _, instance := range c.candidates() {
+ endpoint := instance + path
+ respBody, err := c.postToInstance(ctx, endpoint, body)
+ if err == nil {
+ c.clearCooldown(instance)
+ if err := json.Unmarshal(respBody, dest); err != nil {
+ return fmt.Errorf("biblioreads: error decoding response from %s: %w", endpoint, err)
+ }
+ if c.cache != nil {
+ c.cache.set(cacheKey, respBody)
+ }
+ return nil
+ }
+ var unavailable *unavailableError
+ if !errors.As(err, &unavailable) {
+ return err
+ }
+ if ctx.Err() != nil {
+ return ctx.Err()
+ }
+ c.setCooldown(instance, unavailable.retryAfter)
+ errs = append(errs, err)
+ }
+ return fmt.Errorf("%w: %w", ErrUnavailable, errors.Join(errs...))
+}
+
+// candidates returns the instances that are not cooling down, starting at a random one.
+// If all of them are cooling down, the one whose cooldown ends first is returned, so
+// requests don't fail without trying when every instance had a transient error.
+func (c *Client) candidates() []string {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+
+ now := time.Now()
+ start := rand.IntN(len(c.instances))
+ candidates := make([]string, 0, len(c.instances))
+ soonest := ""
+ for i := range c.instances {
+ instance := c.instances[(start+i)%len(c.instances)]
+ until, coolingDown := c.cooldownUntil[instance]
+ if !coolingDown || !now.Before(until) {
+ candidates = append(candidates, instance)
+ continue
+ }
+ if soonest == "" || until.Before(c.cooldownUntil[soonest]) {
+ soonest = instance
+ }
+ }
+ if len(candidates) == 0 {
+ return []string{soonest}
+ }
+ return candidates
+}
+
+func (c *Client) setCooldown(instance string, d time.Duration) {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ c.cooldownUntil[instance] = time.Now().Add(d)
+}
+
+func (c *Client) clearCooldown(instance string) {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ delete(c.cooldownUntil, instance)
+}
+
+// postToInstance returns the body of a successful response.
+func (c *Client) postToInstance(ctx context.Context, endpoint string, body []byte) ([]byte, error) {
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
+ if err != nil {
+ return nil, err
+ }
+ req.Header.Set("Content-Type", "application/json")
+
+ resp, err := httpjson.Do(c.httpClient, req)
+ if err != nil {
+ return nil, &unavailableError{endpoint: endpoint, retryAfter: httpjson.DefaultRetryAfter, reason: err}
+ }
+
+ if resp.StatusCode == http.StatusTooManyRequests {
+ return nil, &unavailableError{
+ endpoint: endpoint,
+ retryAfter: httpjson.ParseRetryAfter(resp.Header.Get("Retry-After")),
+ reason: fmt.Errorf("status code %d", resp.StatusCode),
+ }
+ }
+
+ // BiblioReads always answers with JSON, even on errors (e.g. a 504 for a book that
+ // doesn't exist), so anything else comes from a broken instance or a proxy in front of it.
+ if !json.Valid(resp.Body) {
+ return nil, &unavailableError{
+ endpoint: endpoint,
+ retryAfter: httpjson.DefaultRetryAfter,
+ reason: fmt.Errorf("status code %d with non-JSON response", resp.StatusCode),
+ }
+ }
+
+ if resp.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("biblioreads: request to %s failed with status code %d: %s", endpoint, resp.StatusCode, resp.Body)
+ }
+ return resp.Body, nil
+}
+
+// escapeQuery escapes a search string using %20 for spaces, as Goodreads does.
+func escapeQuery(query string) string {
+ return strings.ReplaceAll(url.QueryEscape(query), "+", "%20")
+}
diff --git a/internal/datasource/biblioreads/client_test.go b/internal/datasource/biblioreads/client_test.go
new file mode 100644
index 00000000..bb53abaf
--- /dev/null
+++ b/internal/datasource/biblioreads/client_test.go
@@ -0,0 +1,509 @@
+package biblioreads_test
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "sync/atomic"
+ "testing"
+ "time"
+
+ "github.com/svera/coreander/v5/internal/datasource/biblioreads"
+)
+
+type capturedRequest struct {
+ method string
+ path string
+ contentType string
+ payload map[string]any
+}
+
+func newMockServer(t *testing.T, status int, fixture string, captured *capturedRequest) *httptest.Server {
+ t.Helper()
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ captured.method = r.Method
+ captured.path = r.URL.Path
+ captured.contentType = r.Header.Get("Content-Type")
+ if err := json.NewDecoder(r.Body).Decode(&captured.payload); err != nil {
+ t.Errorf("couldn't decode request payload: %v", err)
+ }
+ w.WriteHeader(status)
+ if fixture == "" {
+ _, _ = w.Write([]byte(`{"status":"Error - Invalid Query"}`))
+ return
+ }
+ contents, err := os.ReadFile("fixtures/" + fixture)
+ if err != nil {
+ t.Fatalf("couldn't read fixture %s: %v", fixture, err)
+ }
+ _, _ = w.Write(contents)
+ }))
+ t.Cleanup(server.Close)
+ return server
+}
+
+func assertRequest(t *testing.T, captured capturedRequest, path string, payload map[string]any) {
+ t.Helper()
+ if captured.method != http.MethodPost {
+ t.Errorf("expected POST, got %s", captured.method)
+ }
+ if captured.path != path {
+ t.Errorf("expected path %s, got %s", path, captured.path)
+ }
+ if captured.contentType != "application/json" {
+ t.Errorf("expected application/json content type, got %s", captured.contentType)
+ }
+ if len(captured.payload) != len(payload) {
+ t.Errorf("expected payload %v, got %v", payload, captured.payload)
+ }
+ for key, value := range payload {
+ if captured.payload[key] != value {
+ t.Errorf("expected payload[%s] = %v, got %v", key, value, captured.payload[key])
+ }
+ }
+}
+
+func TestSearchBooks(t *testing.T) {
+ var captured capturedRequest
+ server := newMockServer(t, http.StatusOK, "search-books.json", &captured)
+ client := biblioreads.NewClient(biblioreads.WithInstances([]string{server.URL}), biblioreads.WithHTTPClient(server.Client()), biblioreads.WithCacheTTL(0))
+
+ res, err := client.SearchBooks(context.Background(), "La Reina Dragón Alice Borchardt")
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ assertRequest(t, captured, "/api/search/books", map[string]any{
+ "queryURL": "https://www.goodreads.com/search?q=La%20Reina%20Drag%C3%B3n%20Alice%20Borchardt",
+ })
+
+ if len(res.Result) != 1 {
+ t.Fatalf("expected 1 result, got %d", len(res.Result))
+ }
+ got := res.Result[0]
+ if got.Title != "La Reina Dragon (La leyenda de Guinevere, #1)" ||
+ got.BookURL != "/book/show/12207444-la-reina-dragon" ||
+ got.Author != "Alice Borchardt" ||
+ got.Rating != "3.82" ||
+ got.ID != 1 {
+ t.Errorf("unexpected result: %+v", got)
+ }
+ if res.NumberOfResults != "1 results" || res.SearchType != "books" {
+ t.Errorf("unexpected response metadata: %+v", res)
+ }
+}
+
+func TestBook(t *testing.T) {
+ var captured capturedRequest
+ server := newMockServer(t, http.StatusOK, "book-scraper.json", &captured)
+ client := biblioreads.NewClient(biblioreads.WithInstances([]string{server.URL}), biblioreads.WithHTTPClient(server.Client()), biblioreads.WithCacheTTL(0))
+
+ res, err := client.Book(context.Background(), "12207444-la-reina-dragon")
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ assertRequest(t, captured, "/api/book-scraper", map[string]any{
+ "queryURL": "https://www.goodreads.com/book/show/12207444-la-reina-dragon",
+ })
+
+ if res.LegacyBookID != 12207444 {
+ t.Errorf("expected legacy book ID 12207444, got %d", res.LegacyBookID)
+ }
+ if res.ResourceID != "kca://work/amzn1.gr.work.v1.dbcQjzlbvXR8RBZBdd8J6g" {
+ t.Errorf("unexpected resource ID %s", res.ResourceID)
+ }
+ if res.Title != "La Reina Dragon" || res.Series != "Tales of Guinevere" || res.SeriesPosition != "1" {
+ t.Errorf("unexpected book data: %+v", res)
+ }
+ if len(res.Author) != 1 || res.Author[0].Name != "Alice Borchardt" || res.Author[0].Role != "Author" {
+ t.Errorf("unexpected authors: %+v", res.Author)
+ }
+ if len(res.Genres) != 10 || res.Genres[0] != "Fantasy" {
+ t.Errorf("unexpected genres: %v", res.Genres)
+ }
+ if res.ISBN13 != "9788448033187" || res.Language != "Spanish; Castilian" {
+ t.Errorf("unexpected book data: %+v", res)
+ }
+ if res.ReviewBreakdown.Rating5 != "726" {
+ t.Errorf("unexpected review breakdown: %+v", res.ReviewBreakdown)
+ }
+}
+
+func TestReviews(t *testing.T) {
+ t.Run("first page", func(t *testing.T) {
+ var captured capturedRequest
+ server := newMockServer(t, http.StatusOK, "reviews-scraper.json", &captured)
+ client := biblioreads.NewClient(biblioreads.WithInstances([]string{server.URL}), biblioreads.WithHTTPClient(server.Client()), biblioreads.WithCacheTTL(0))
+
+ res, err := client.Reviews(context.Background(), "kca://work/amzn1.gr.work.v1.dbcQjzlbvXR8RBZBdd8J6g", 12207444, "")
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ assertRequest(t, captured, "/api/reviews-scraper", map[string]any{
+ "resourceID": "kca://work/amzn1.gr.work.v1.dbcQjzlbvXR8RBZBdd8J6g",
+ "legacyBookID": float64(12207444),
+ })
+
+ if res.TotalCount != 115 || res.NextPageToken != "NTgsMTcwNTAxNjc4NjQzNA" {
+ t.Errorf("unexpected response metadata: %+v", res)
+ }
+ if len(res.Reviews) == 0 {
+ t.Fatal("expected reviews, got none")
+ }
+ got := res.Reviews[0]
+ if got.Rating != 5 || got.Shelf != "read" || got.LikeCount != 18 || got.RecommendFor != nil {
+ t.Errorf("unexpected review: %+v", got)
+ }
+ if got.Reviewer.ID != 1259454 || got.Reviewer.Name != "Shawna Astley" || got.Reviewer.ReviewCount != 42 {
+ t.Errorf("unexpected reviewer: %+v", got.Reviewer)
+ }
+ })
+
+ t.Run("next page", func(t *testing.T) {
+ var captured capturedRequest
+ server := newMockServer(t, http.StatusOK, "reviews-scraper.json", &captured)
+ client := biblioreads.NewClient(biblioreads.WithInstances([]string{server.URL}), biblioreads.WithHTTPClient(server.Client()), biblioreads.WithCacheTTL(0))
+
+ if _, err := client.Reviews(context.Background(), "kca://work/abc", 1, "token"); err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ assertRequest(t, captured, "/api/reviews-scraper", map[string]any{
+ "resourceID": "kca://work/abc",
+ "legacyBookID": float64(1),
+ "nextPageToken": "token",
+ })
+ })
+}
+
+func TestErrorStatus(t *testing.T) {
+ var captured capturedRequest
+ server := newMockServer(t, http.StatusNotFound, "", &captured)
+ client := biblioreads.NewClient(biblioreads.WithInstances([]string{server.URL}), biblioreads.WithHTTPClient(server.Client()), biblioreads.WithCacheTTL(0))
+
+ if _, err := client.Book(context.Background(), "0"); err == nil {
+ t.Error("expected error on non-200 response, got nil")
+ }
+}
+
+func newCountingServer(t *testing.T, status int, body string, hits *atomic.Int32) *httptest.Server {
+ t.Helper()
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ hits.Add(1)
+ w.WriteHeader(status)
+ _, _ = w.Write([]byte(body))
+ }))
+ t.Cleanup(server.Close)
+ return server
+}
+
+func TestFailover(t *testing.T) {
+ t.Run("unavailable instances are skipped", func(t *testing.T) {
+ var unavailableHits, rateLimitedHits, htmlHits, workingHits atomic.Int32
+ unavailable := newCountingServer(t, http.StatusBadGateway, "Bad Gateway", &unavailableHits)
+ rateLimited := newCountingServer(t, http.StatusTooManyRequests, `{"status":"Too many requests"}`, &rateLimitedHits)
+ html := newCountingServer(t, http.StatusOK, "Parked domain", &htmlHits)
+ working := newCountingServer(t, http.StatusOK, `{"status":"Received","title":"La Reina Dragon"}`, &workingHits)
+ down := httptest.NewServer(http.NotFoundHandler())
+ down.Close()
+
+ instances := []string{down.URL, unavailable.URL, rateLimited.URL, html.URL, working.URL}
+ for range 20 {
+ client := biblioreads.NewClient(biblioreads.WithInstances(instances), biblioreads.WithCacheTTL(0))
+ res, err := client.Book(context.Background(), "12207444")
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if res.Title != "La Reina Dragon" {
+ t.Fatalf("unexpected title %q", res.Title)
+ }
+ }
+ if workingHits.Load() != 20 {
+ t.Errorf("expected working instance to be hit 20 times, got %d", workingHits.Load())
+ }
+ })
+
+ t.Run("requests start at different instances", func(t *testing.T) {
+ var firstHits, secondHits atomic.Int32
+ first := newCountingServer(t, http.StatusOK, `{"status":"Received"}`, &firstHits)
+ second := newCountingServer(t, http.StatusOK, `{"status":"Received"}`, &secondHits)
+
+ client := biblioreads.NewClient(biblioreads.WithInstances([]string{first.URL, second.URL}), biblioreads.WithCacheTTL(0))
+ for range 100 {
+ if _, err := client.Book(context.Background(), "1"); err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ }
+ if firstHits.Load() == 0 || secondHits.Load() == 0 {
+ t.Errorf("expected load to be spread, got %d and %d hits", firstHits.Load(), secondHits.Load())
+ }
+ })
+
+ t.Run("JSON error responses are not retried", func(t *testing.T) {
+ var hits atomic.Int32
+ first := newCountingServer(t, http.StatusNotFound, `{"status":"Error - Invalid Query"}`, &hits)
+ second := newCountingServer(t, http.StatusNotFound, `{"status":"Error - Invalid Query"}`, &hits)
+
+ client := biblioreads.NewClient(biblioreads.WithInstances([]string{first.URL, second.URL}), biblioreads.WithCacheTTL(0))
+ _, err := client.Book(context.Background(), "0")
+ if err == nil {
+ t.Fatal("expected error, got nil")
+ }
+ if errors.Is(err, biblioreads.ErrUnavailable) {
+ t.Errorf("expected a non-ErrUnavailable error, got %v", err)
+ }
+ if hits.Load() != 1 {
+ t.Errorf("expected a single request, got %d", hits.Load())
+ }
+ })
+
+ t.Run("error when all instances fail", func(t *testing.T) {
+ var hits atomic.Int32
+ first := newCountingServer(t, http.StatusServiceUnavailable, "", &hits)
+ second := newCountingServer(t, http.StatusServiceUnavailable, "", &hits)
+
+ client := biblioreads.NewClient(biblioreads.WithInstances([]string{first.URL, second.URL}), biblioreads.WithCacheTTL(0))
+ if _, err := client.Book(context.Background(), "1"); !errors.Is(err, biblioreads.ErrUnavailable) {
+ t.Fatalf("expected ErrUnavailable, got %v", err)
+ }
+ if hits.Load() != 2 {
+ t.Errorf("expected both instances to be tried, got %d requests", hits.Load())
+ }
+ })
+
+ t.Run("cancelled context stops failover", func(t *testing.T) {
+ var hits atomic.Int32
+ first := newCountingServer(t, http.StatusOK, `{}`, &hits)
+ second := newCountingServer(t, http.StatusOK, `{}`, &hits)
+
+ ctx, cancel := context.WithCancel(context.Background())
+ cancel()
+ client := biblioreads.NewClient(biblioreads.WithInstances([]string{first.URL, second.URL}), biblioreads.WithCacheTTL(0))
+ if _, err := client.Book(ctx, "1"); err == nil {
+ t.Fatal("expected error, got nil")
+ }
+ if hits.Load() != 0 {
+ t.Errorf("expected no requests, got %d", hits.Load())
+ }
+ })
+}
+
+func TestCooldown(t *testing.T) {
+ t.Run("failing instance is skipped on later requests", func(t *testing.T) {
+ var badHits, goodHits atomic.Int32
+ bad := newCountingServer(t, http.StatusBadGateway, "Bad Gateway", &badHits)
+ good := newCountingServer(t, http.StatusOK, `{"status":"Received"}`, &goodHits)
+
+ client := biblioreads.NewClient(biblioreads.WithInstances([]string{bad.URL, good.URL}), biblioreads.WithCacheTTL(0))
+ for range 20 {
+ if _, err := client.Book(context.Background(), "1"); err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ }
+ if badHits.Load() > 1 {
+ t.Errorf("expected failing instance to be hit at most once, got %d", badHits.Load())
+ }
+ if goodHits.Load() != 20 {
+ t.Errorf("expected working instance to be hit 20 times, got %d", goodHits.Load())
+ }
+ })
+
+ t.Run("instance is used again after Retry-After", func(t *testing.T) {
+ var limitedHits, otherHits atomic.Int32
+ limited := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if limitedHits.Add(1) == 1 {
+ w.Header().Set("Retry-After", "1")
+ w.WriteHeader(http.StatusTooManyRequests)
+ return
+ }
+ _, _ = w.Write([]byte(`{"status":"Received"}`))
+ }))
+ t.Cleanup(limited.Close)
+ other := newCountingServer(t, http.StatusOK, `{"status":"Received"}`, &otherHits)
+
+ client := biblioreads.NewClient(biblioreads.WithInstances([]string{limited.URL, other.URL}), biblioreads.WithCacheTTL(0))
+ for limitedHits.Load() == 0 {
+ if _, err := client.Book(context.Background(), "1"); err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ }
+ for range 20 {
+ if _, err := client.Book(context.Background(), "1"); err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ }
+ if limitedHits.Load() != 1 {
+ t.Fatalf("expected rate limited instance to be skipped during cooldown, got %d hits", limitedHits.Load())
+ }
+
+ time.Sleep(1100 * time.Millisecond)
+ for range 20 {
+ if _, err := client.Book(context.Background(), "1"); err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ }
+ if limitedHits.Load() == 1 {
+ t.Error("expected rate limited instance to be used again after its cooldown")
+ }
+ })
+
+ t.Run("instance is probed when all are cooling down", func(t *testing.T) {
+ var hits atomic.Int32
+ flaky := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if hits.Add(1) == 1 {
+ w.WriteHeader(http.StatusServiceUnavailable)
+ return
+ }
+ _, _ = w.Write([]byte(`{"status":"Received"}`))
+ }))
+ t.Cleanup(flaky.Close)
+
+ client := biblioreads.NewClient(biblioreads.WithInstances([]string{flaky.URL}), biblioreads.WithCacheTTL(0))
+ if _, err := client.Book(context.Background(), "1"); err == nil {
+ t.Fatal("expected error on first request, got nil")
+ }
+ if _, err := client.Book(context.Background(), "1"); err != nil {
+ t.Fatalf("expected cooling down instance to be probed, got %v", err)
+ }
+ })
+
+ t.Run("JSON error responses don't trigger cooldown", func(t *testing.T) {
+ var firstHits, secondHits atomic.Int32
+ notFound := `{"status":"Error - Book not found"}`
+ first := newCountingServer(t, http.StatusGatewayTimeout, notFound, &firstHits)
+ second := newCountingServer(t, http.StatusGatewayTimeout, notFound, &secondHits)
+
+ client := biblioreads.NewClient(biblioreads.WithInstances([]string{first.URL, second.URL}), biblioreads.WithCacheTTL(0))
+ for range 20 {
+ if _, err := client.Book(context.Background(), "0"); err == nil {
+ t.Fatal("expected error, got nil")
+ }
+ }
+ if firstHits.Load()+secondHits.Load() != 20 {
+ t.Errorf("expected one request per call, got %d", firstHits.Load()+secondHits.Load())
+ }
+ if firstHits.Load() == 0 || secondHits.Load() == 0 {
+ t.Errorf("expected no instance to be benched, got %d and %d hits", firstHits.Load(), secondHits.Load())
+ }
+ })
+
+ t.Run("cancelled requests don't trigger cooldown", func(t *testing.T) {
+ var firstHits, secondHits atomic.Int32
+ first := newCountingServer(t, http.StatusOK, `{}`, &firstHits)
+ second := newCountingServer(t, http.StatusOK, `{}`, &secondHits)
+
+ client := biblioreads.NewClient(biblioreads.WithInstances([]string{first.URL, second.URL}), biblioreads.WithCacheTTL(0))
+ ctx, cancel := context.WithCancel(context.Background())
+ cancel()
+ for range 5 {
+ _, _ = client.Book(ctx, "1")
+ }
+ for range 40 {
+ if _, err := client.Book(context.Background(), "1"); err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ }
+ if firstHits.Load() == 0 || secondHits.Load() == 0 {
+ t.Errorf("expected no instance to be benched, got %d and %d hits", firstHits.Load(), secondHits.Load())
+ }
+ })
+}
+
+func TestCache(t *testing.T) {
+ t.Run("successful responses are cached per request", func(t *testing.T) {
+ var hits atomic.Int32
+ server := newCountingServer(t, http.StatusOK, `{"status":"Received","title":"La Reina Dragon","genres":["Fantasy"]}`, &hits)
+ client := biblioreads.NewClient(biblioreads.WithInstances([]string{server.URL}))
+
+ first, err := client.Book(context.Background(), "1")
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ first.Genres[0] = "modified"
+
+ second, err := client.Book(context.Background(), "1")
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if hits.Load() != 1 {
+ t.Errorf("expected cached response to be used, got %d requests", hits.Load())
+ }
+ if second.Title != "La Reina Dragon" || second.Genres[0] != "Fantasy" {
+ t.Errorf("expected an unmodified copy of the cached response, got %+v", second)
+ }
+
+ if _, err := client.Book(context.Background(), "2"); err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if _, err := client.SearchBooks(context.Background(), "1"); err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if _, err := client.Reviews(context.Background(), "kca://work/abc", 1, ""); err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if _, err := client.Reviews(context.Background(), "kca://work/abc", 1, "token"); err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if hits.Load() != 5 {
+ t.Errorf("expected different requests not to share cache entries, got %d requests", hits.Load())
+ }
+ })
+
+ t.Run("entries expire after the TTL", func(t *testing.T) {
+ var hits atomic.Int32
+ server := newCountingServer(t, http.StatusOK, `{"status":"Received"}`, &hits)
+ client := biblioreads.NewClient(biblioreads.WithInstances([]string{server.URL}), biblioreads.WithCacheTTL(100*time.Millisecond))
+
+ for range 3 {
+ if _, err := client.Book(context.Background(), "1"); err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ }
+ if hits.Load() != 1 {
+ t.Fatalf("expected 1 request before expiry, got %d", hits.Load())
+ }
+
+ time.Sleep(150 * time.Millisecond)
+ if _, err := client.Book(context.Background(), "1"); err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if hits.Load() != 2 {
+ t.Errorf("expected expired entry to be refreshed, got %d requests", hits.Load())
+ }
+ })
+
+ t.Run("errors are not cached", func(t *testing.T) {
+ var hits atomic.Int32
+ server := newCountingServer(t, http.StatusGatewayTimeout, `{"status":"Error - Book not found"}`, &hits)
+ client := biblioreads.NewClient(biblioreads.WithInstances([]string{server.URL}))
+
+ for range 3 {
+ if _, err := client.Book(context.Background(), "0"); err == nil {
+ t.Fatal("expected error, got nil")
+ }
+ }
+ if hits.Load() != 3 {
+ t.Errorf("expected every failed request to reach the server, got %d requests", hits.Load())
+ }
+ })
+
+ t.Run("caching can be disabled", func(t *testing.T) {
+ var hits atomic.Int32
+ server := newCountingServer(t, http.StatusOK, `{"status":"Received"}`, &hits)
+ client := biblioreads.NewClient(biblioreads.WithInstances([]string{server.URL}), biblioreads.WithCacheTTL(0))
+
+ for range 3 {
+ if _, err := client.Book(context.Background(), "1"); err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ }
+ if hits.Load() != 3 {
+ t.Errorf("expected 3 requests with caching disabled, got %d", hits.Load())
+ }
+ })
+}
diff --git a/internal/datasource/biblioreads/fixtures/book-scraper.json b/internal/datasource/biblioreads/fixtures/book-scraper.json
new file mode 100644
index 00000000..5b8abc6a
--- /dev/null
+++ b/internal/datasource/biblioreads/fixtures/book-scraper.json
@@ -0,0 +1,60 @@
+{
+ "status": "Received",
+ "statusCode": 200,
+ "source": "https://github.com/nesaku/biblioreads",
+ "scrapeURL": "https://www.goodreads.com/book/show/12207444-la-reina-dragon",
+ "legacyBookID": 12207444,
+ "bookID": "kca://book/amzn1.gr.book.v1.4FDu7eHal9NDfA3jGNhOXw",
+ "resourceID": "kca://work/amzn1.gr.work.v1.dbcQjzlbvXR8RBZBdd8J6g",
+ "cover": "https://m.media-amazon.com/images/S/compressed.photo.goodreads.com/books/1332599049i/12207444.jpg",
+ "series": "Tales of Guinevere",
+ "seriesURL": "/series/50072-tales-of-guinevere",
+ "seriesPosition": "1",
+ "workURL": "https://www.goodreads.com/book/show/12207444-la-reina-dragon",
+ "title": "La Reina Dragon",
+ "author": [
+ {
+ "id": 1,
+ "name": "Alice Borchardt",
+ "role": "Author",
+ "url": "/author/show/151155.Alice_Borchardt"
+ }
+ ],
+ "rating": "3.82",
+ "ratingCount": "2,285",
+ "reviewsCount": "115",
+ "desc": "Arturo se dio la vuelta y vino resueltamente hacia nosotros. Estaba espléndido, y nunca olvidaré que en ese momento comencé a amarlo. Y ya nunca dejé de hacerlo. Nunca nadie me ha provocado tanta pena, dolor y alegría como él. Ni siquiera por mis hijos he sentido el amor que le profeso todavía ahora. Y creo que aunque hubiera sabido lo que nos deparaba el futuro (preocupaciones, tormentos, batallas y penas), aun así levaría entregado mi corazón tal como hice entonces.\r\n\r\nAlice Brondchardt entra en el reino de la leyenda artúrica y lo hace suyo. La reina Dragón es el primer volumen de la trilogía que recrea el mundo de Camelot con la reina Guinevere convertida en una guerrea sagaz y obstinada.\r\n\r\nNacida en un entorno en el que los enfrentamientos son constantes y las armas están siempre presentes en la vida de hombres y mujeres, Guinevere, hija de una poderosa reina pagana, es una amenaza para su gente y una preciada presa para el temible mago Merlín.\r\n\r\nEnviada a u lugar secreto, crece bajo la protección de un hombre-lobo y de un druida de agrio carácter. Pero ni siquiera en las remotas costas de Escocia, donde los dragones velan por ella, está a salvo del todopoderoso mago. Merlín conoce el destino de la bella joven y no se detendrá ante nada para evitar el cumplimiento de la profecía, puesto que si Guinevere y Arturo llegan a reinar, llevarán un paz al país que convertirá a Merlín, hambriento de poder, en un personaje secundario.",
+ "genres": [
+ "Fantasy",
+ "Arthurian",
+ "Fiction",
+ "Historical Fiction",
+ "Dragons",
+ "Historical",
+ "Mythology",
+ "Magic",
+ "Science Fiction Fantasy",
+ "Retellings"
+ ],
+ "bookEdition": "452 pages, Hardcover",
+ "publishDate": "Wed Jan 01 2003",
+ "publisher": "Timun Mas",
+ "language": "Spanish; Castilian",
+ "asin": "8448033183",
+ "isbn": "8448033183",
+ "isbn13": "9788448033187",
+ "related": [],
+ "reviews": [],
+ "reviewBreakdown": {
+ "rating1": "72",
+ "rating2": "170",
+ "rating3": "586",
+ "rating4": "731",
+ "rating5": "726"
+ },
+ "quotes": "4",
+ "quotesURL": "/work/quotes/2440721",
+ "questions": "1",
+ "questionsURL": "/book/258666/questions",
+ "lastScraped": "2026-10-28T18:34:27.525Z"
+}
diff --git a/internal/datasource/biblioreads/fixtures/reviews-scraper.json b/internal/datasource/biblioreads/fixtures/reviews-scraper.json
new file mode 100644
index 00000000..1d7908c5
--- /dev/null
+++ b/internal/datasource/biblioreads/fixtures/reviews-scraper.json
@@ -0,0 +1,672 @@
+{
+ "status": "Received",
+ "statusCode": 200,
+ "source": "https://github.com/nesaku/biblioreads",
+ "scrapeURL": "https://www.goodreads.com/book/show/12207444/reviews",
+ "totalCount": 115,
+ "nextPageToken": "NTgsMTcwNTAxNjc4NjQzNA",
+ "prevPageToken": "MTgwMSwxMjE0MTUxODU5MDAw",
+ "reviews": [
+ {
+ "id": "kca://review:goodreads/amzn1.gr.review:goodreads.v1.gH7Sf4_G4iz72wvI_yVVyg",
+ "reviewer": {
+ "id": 1259454,
+ "name": "Shawna Astley",
+ "url": "/user/show/1259454-shawna-astley",
+ "avatar": "https://i.gr-assets.com/images/S/compressed.photo.goodreads.com/users/1214150161i/1259454._UY200_CR33,0,200,200_.jpg",
+ "isAuthor": false,
+ "reviewCount": 42,
+ "followerCount": 0
+ },
+ "rating": 5,
+ "text": "Women fighting men with swords - whats not to like? ",
+ "createdAt": "Sun Jun 22 2008",
+ "updatedAt": "Tue Jan 07 2025",
+ "spoiler": false,
+ "likeCount": 18,
+ "commentCount": 0,
+ "recommendFor": null,
+ "shelf": "read",
+ "reviewURL": "/review/show/25118938"
+ },
+ {
+ "id": "kca://review:goodreads/amzn1.gr.review:goodreads.v1.be94OXB9ezWIlFZvdGMKvA",
+ "reviewer": {
+ "id": 2478293,
+ "name": "Zeek",
+ "url": "/user/show/2478293-zeek",
+ "avatar": "https://i.gr-assets.com/images/S/compressed.photo.goodreads.com/users/1339154824i/2478293.jpg",
+ "isAuthor": false,
+ "reviewCount": 939,
+ "followerCount": 149
+ },
+ "rating": 3,
+ "text": "Although I liked this tale overall, there were pieces of it that bogged the story down for me.
The Dragon Queen is yet another entry into the tales of Arthur and The Knights of the Round Table. For those who love their Arthurian legends with Merlin as the big bad- then this book's for you. For those who don't-then Run away!
TDQ centers around Guinevere, the warrior queen of Arthur and in Borchardt's version she is indeed a warrior. Guinevere, has the beauty of previous Arthurian tales, but in TDQ she's not the sweet little misguided star-crossed lover of Lancelot that we usually see her portrayed to be. Courageous and bold she has power in her right hand where ferocious fire emanates (grand super power that!), and healing in her left. Moreover, arrows flyout her bow as if an extension of her arm, she communicates with dragons- as well with the dead- displays prophetic abilities, and audaciously defeats asundry of evil beasts that Merlin flings at her.
The story begins with Maeniel, Guinevere's soon to be foster father. Maeniel, otherwise knows as the Gray Watcher, is a shapeshifting man/wolf who has personally felt the destructive powers of Merlin. Deciding that being a wolf was much preferred to living with the humans, he escapes to the wilds and begins a family. It is during his time with the wolves that Guinevere falls into his care, and the Gray Watcher, along with the sorcerer Dugald- who Merlin chased into hiding long ago- take up the charge of raising her. The Gray Watcher's wolf mate nurses Guinevere from the time they find her left on a cliff and later a woman joins them who will teach her the ways of her people- a people she never knew.
As a child, Guinevere helps her village to defeat a marauding group of seafarers- and it is here where we see her potential for greatness begin to come forth. Guinevere grows in stature, wisdom and power and, though still only a young teen through much of the story, she quickly finds herself thrust into peril. Merlin wants her dead, and though the author details many things in TDQ, I never quite got the gist of his motive for seeking to destroy her so passionately. The only thing I can figure is that she must be the one who either precipitates or actually carries out Merlin's eventual destruction.
Many other side characters show up to aid or engage her in battle. There are all kinds of fantasy creatures- including mythological gods- that she meets up with and, eventually, she meets her future in the boy of Arthur.
Arthur is just as strong as she- and closer to her age then what I've seen portrayed before. Set long before Camalot, he's young and only discovering who and what he's eventually going to be. Much of the middle of the book is from his POV, where he meets the Lady of the Lake. I was disappointed however, that at the end of TDQ, he and Guinevere do not meet up again. (I suppose that will come in later installments.)
The jumping around of POV's was rather confusing for me and the author lost me a bit until it began to pick up again toward the end. The book is full of detailed descriptions of the world Borchardt has created and some of it became overwhelming. To me her descriptions dragged the story down and lost me for most of the middle of the book. But, as I said before, by the end I was back with her.
This is definitely sequel bait- the story is far from over. I'm interested to see where the author- who was Anne Rice's sister, btw- will take it.",
+ "createdAt": "Thu May 20 2010",
+ "updatedAt": "Sun May 24 2026",
+ "spoiler": false,
+ "likeCount": 10,
+ "commentCount": 4,
+ "recommendFor": null,
+ "shelf": "read",
+ "reviewURL": "/review/show/103546143"
+ },
+ {
+ "id": "kca://review:goodreads/amzn1.gr.review:goodreads.v1.sC0IQDlAXY4LbkJ3LTJHvA",
+ "reviewer": {
+ "id": 80386185,
+ "name": "Rial",
+ "url": "/user/show/80386185-rial",
+ "avatar": "https://i.gr-assets.com/images/S/compressed.photo.goodreads.com/users/1545932910i/80386185._UX200_CR0,0,200,200_.jpg",
+ "isAuthor": false,
+ "reviewCount": 30,
+ "followerCount": 0
+ },
+ "rating": 1,
+ "text": "I did not like this book. However, it is my first 1 Star, so it's got that going for it.
Why did I read this? It was selected for my wife and roommate's book club, and I parasitically piggyback a lot of their books (gives me something to talk about...). It's Arthurian! It's from Guinivere's perspective! Magic! Celtic! Post 'Fall of the Western Empire'! These things and more!
No, it's terrible. Why did I keep reading it? It did take me on a journey: I experienced all the 'Stages of Grief' (some of them more than once.
Denial: Ha, it's so weird, but it'll get better! The chapters are long, but that's okay! Weird, the perspective shifted...? Anger: I liked the other character; this one is dumb. She's a Mary Sue! WTF? That just worked out for her with zero effort! Okay, she needs to show some growth... Bargaining: ...Okay, the parts with Arthur aren't bad, maybe? Anger: Why are they having revenge sex in the forest? Denial: Arthur is cool, yes, he's very cool... :| Depression: I really shouldn't be reading this anymore; I think it's actively hurting me. Acceptance: There is only book. Oh yeah, I guess your shoes can do that. Yep, you're definitely super fucking hot, and everyone wants to be or be with you. Sure why not? I don't have enough brain power to think it should be any different. After all, you're the best, Gwen!
But the real joy of this book is in not starting it. If you made that mistake and you're too masochistic to give up, then finishing it is an exultant experience
The contents are one thing, but they aren't even well served by the writing. Transitions are abrupt and jarring. I back-read multiple times to try and figure out where the hell I was, only to realize there wasn't any explanation; it just got there and it was fine (it wasn't fine).
This thing had such potential: Arthurian set in the 'proper' time frame, the Christianization of Britannia and the clashes with pagan beliefs that ensue, Celtic and Brythonic myth woven throughout. But it all reads so poorly in the end. The author is far too verbose and floral with her language. First person POV ruins any sense of actual accomplishment because we only see the character succeeding; no real growth or change is experienced. I find Divine Right to be problematic, and this book is very much into the romanticism of a destined ruler (both for Arthur and for Gwen).
Oh yeah, Merlin is evil and helps Arthur's mom torment Arthur, I guess? Like, if you hate the kid so much just kill him? Oh wait, 'destiny'...
Tedious tripe, glorified garbage, wretched rubbish. What a waste of time.",
+ "createdAt": "Mon Nov 05 2018",
+ "updatedAt": "Fri Jun 10 2022",
+ "spoiler": false,
+ "likeCount": 6,
+ "commentCount": 2,
+ "recommendFor": null,
+ "shelf": "read",
+ "reviewURL": "/review/show/2578561727"
+ },
+ {
+ "id": "kca://review:goodreads/amzn1.gr.review:goodreads.v1.rwxYpuQEynxP2f5dSLFmPQ",
+ "reviewer": {
+ "id": 90733221,
+ "name": "Monique Mihalitsianos",
+ "url": "/user/show/90733221-monique-mihalitsianos",
+ "avatar": "https://i.gr-assets.com/images/S/compressed.photo.goodreads.com/users/1773156601i/90733221._UX200_CR0,29,200,200_.jpg",
+ "isAuthor": true,
+ "reviewCount": 88,
+ "followerCount": 9
+ },
+ "rating": 3,
+ "text": "This book was splendid in the story-telling sense. The story was epic and magical and engaging. Also, this is, by far, the best Guinevere I have encountered in modern fiction so far. She was strong but not brass, brave but not stupid, powerful yet humble, compassionate but not weak, feminine yet full of agency. Perhaps what I liked the most about her was that she was in communion with animals and gods alike and knew her place in the wheel of existence. She was wise like that, but never overbearingly so. I loved her. I wanted to *be* her.
Arthur I also liked very much, and I liked that this was a tortured Arthur who had suffered child abuse at the hands of her horrible queen mother Igrane (*the* worst evil queen ever) but was still good and noble and could not lie and never feared danger. I liked his relationship with his father, Uther, the High King, which was refreshingly healthy and loving. Merlin I hated. This is a book with a bad Merlin in it, which I don't like as a trope. I much prefer my Merlins to be good and Gandalf-like, but in this case it worked to have him be so evil and act as the villain. The black magic he wrought was one of the sickest and most horrifying ones I've ever read in a story.
Alice Borchardt seems to know her history well, so much so in fact that it's almost like if she lived in this period of time (Roman - Saxon) in a past life. I, for one, appreciated all of the extraneous detail relating to the customs and mores of the time because it made the world-building that much better.
The magic system in this book was refreshingly deep and believable. So many magic systems in recent fantasy books are light and superficial, as if they had been thought up in brainstorming sessions instead of being fully inspired. Not so with Borchardt's story; the magic here seemed inspired by the author's own psyche and thus, was multilayered and poliphacetic, changeable and malleable but also essentially true to its own core.
I also liked that an existencial and historical struggle was hinted at: the convergence between paganism and Christianity, if there can be a convergence at all. It was only hinted at here but I wouldn't mind if this theme in particular were elaborated more in the second book (The Raven Warrior) because it is of particular interest to me. On some level I think it was also of particular interest to Borchardt, as well. I am a huge fan of Anne Rice's (Alice Borchardt's sister) Vampire Chronicles, and Rice definitely waxes philosophical about her own struggles with different faith systems and beliefs in her own fiction, much more so than Borchardt, who chose to focus more on the story. But I wouldn't have minded if she had expanded on this in her fiction, just like her sister did. But then again, I am of a philosophical bent and like these kinds of tangents.
Why the three-star rating, then, if I liked the book so much? Well, the story was golden, but the writing was confusing at times, especially at the beginning and end. Sometimes it was almost stream-of-consciousness like, and I got lost and confused. At other times, when a character referred to somebody else as 'she', for example, I didn't know who they were talking about, because there were several 'she's' they could have been referring to. So some more specificity in this and other similar instances would have been appreciated. Sometimes, also, the writing got too dream-like, too airy fairy and dreamy...and perhaps more concrete writing would have served the book better overall. I also suspect this book could have had tighter editing. I know for a fact Anne Rice is not a fan of people editing her writing, and I don't know if Alice Borchardt felt the same as her sister does in this matter, but I for one think good editing is absolutely crucial in any book.
Putting this aside, though, I can say that I thoroughly enjoyed this book and am looking forward to reading the second and final installment. This was meant to be a trilogy, but unfortunately, the author died from illness before she could complete the third book, so two books is all we have. I will read her other books as well, though. She's a gem of a story-teller, at least in my estimation.",
+ "createdAt": "Wed Mar 18 2020",
+ "updatedAt": "Thu Nov 30 2023",
+ "spoiler": false,
+ "likeCount": 3,
+ "commentCount": 0,
+ "recommendFor": null,
+ "shelf": "read",
+ "reviewURL": "/review/show/3208179930"
+ },
+ {
+ "id": "kca://review:goodreads/amzn1.gr.review:goodreads.v1.XvL2nHk7Bt4tULNXUE2yaA",
+ "reviewer": {
+ "id": 2207539,
+ "name": "Steen",
+ "url": "/user/show/2207539-steen",
+ "avatar": "https://i.gr-assets.com/images/S/compressed.photo.goodreads.com/users/1408254648i/2207539._UY200_CR33,0,200,200_.jpg",
+ "isAuthor": false,
+ "reviewCount": 473,
+ "followerCount": 4
+ },
+ "rating": 1,
+ "text": "This was a did not finish book for me.
So after reading about 185 pages of The Dragon Queen by Alice Borchardt I have decided to thrown it down and stop reading. I thought I would like it being about Quinevere and all but I hate it. It's not like the Arthur legends I've ever known. But that's not really even it though. I could handle a different take on the stories ( I still have yet to read any books about Arthur that are out there that are more well known) it's just the way she has written it. She jumps from one place to another and it's like when did that happen? Also sometimes when there are a few people talking 'she says...' your like who is talking? Which one is saying what. UGH. I hate not finishing a book but I really can't continue to read it anymore.
Having said that I really have no urge to read Bequiled which is also by her. I read the first in that series and while it was better written then The Dragon Queen I really don't love the characters in the first book to continue to read the second one anymore. Not after trying to get through The Dragon Queen and hating it. It's sad but yeah. I love Anne Rice's books and hoped that I would equally like her sister's books. But I guess they are just not for me.
I've read reviews on these two books and it sounds like her werewolf books are pretty good but I think I may wait a while to even attempt to try those ones.
So now its off to the library today to pick up some more to read.",
+ "createdAt": "Sat Apr 11 2009",
+ "updatedAt": "Wed Jan 23 2019",
+ "spoiler": false,
+ "likeCount": 3,
+ "commentCount": 3,
+ "recommendFor": null,
+ "shelf": "read",
+ "reviewURL": "/review/show/52264098"
+ },
+ {
+ "id": "kca://review:goodreads/amzn1.gr.review:goodreads.v1.rZ5icbmFfLHPKpEQOm2kNw",
+ "reviewer": {
+ "id": 41814516,
+ "name": "Alberto García",
+ "url": "/user/show/41814516-alberto-garc-a",
+ "avatar": "https://i.gr-assets.com/images/S/compressed.photo.goodreads.com/users/1508706260i/41814516._UX200_CR0,33,200,200_.jpg",
+ "isAuthor": true,
+ "reviewCount": 45,
+ "followerCount": 40
+ },
+ "rating": 4,
+ "text": "Voto de confianza... esta m¡¡bien escrito y la revisión de la leyenda artúrica es muy interesante. Al final, en algunos capítulos, se le va un poco la pinza con salidas algo paranoicas que me han dejado bastante descolocado... lo que es seguro es que no me lo esperaba... pero tampoco comprendo demasiado la razón para ello. Veremos cómo acaba y si se queda en una anécdota.",
+ "createdAt": "Fri Jun 21 2019",
+ "updatedAt": "Mon Sep 14 2026",
+ "spoiler": false,
+ "likeCount": 3,
+ "commentCount": 0,
+ "recommendFor": null,
+ "shelf": "read",
+ "reviewURL": "/review/show/2796875885"
+ },
+ {
+ "id": "kca://review:goodreads/amzn1.gr.review:goodreads.v1.G3-a2b8-oopoAbpH6RmbsA",
+ "reviewer": {
+ "id": 73527602,
+ "name": "Rachel",
+ "url": "/user/show/73527602-rachel",
+ "avatar": "",
+ "isAuthor": false,
+ "reviewCount": 13,
+ "followerCount": 0
+ },
+ "rating": 3,
+ "text": "This was like reading someone's dream. The ideas were all loosely connected but the transitions were weird and random or completely non-existent. However, the characters and concepts were fun and interesting and I did like this book.",
+ "createdAt": "Fri Jan 04 2019",
+ "updatedAt": "Mon Nov 17 2025",
+ "spoiler": false,
+ "likeCount": 4,
+ "commentCount": 0,
+ "recommendFor": null,
+ "shelf": "read",
+ "reviewURL": "/review/show/2582029737"
+ },
+ {
+ "id": "kca://review:goodreads/amzn1.gr.review:goodreads.v1.juoLRbi8R_urzbj6jgRAww",
+ "reviewer": {
+ "id": 101256289,
+ "name": "Malischa Nijenhuis",
+ "url": "/user/show/101256289-malischa-nijenhuis",
+ "avatar": "https://i.gr-assets.com/images/S/compressed.photo.goodreads.com/users/1773261867i/101256289._UX200_CR0,0,200,200_.jpg",
+ "isAuthor": false,
+ "reviewCount": 1222,
+ "followerCount": 32
+ },
+ "rating": 5,
+ "text": "Eigenlijks lees ik nu een boek van mijn man maar wauw wat is dit een gave uitgave en boek. Nu op naar boek 2",
+ "createdAt": "Sat Mar 21 2020",
+ "updatedAt": "Mon Mar 23 2020",
+ "spoiler": false,
+ "likeCount": 2,
+ "commentCount": 0,
+ "recommendFor": null,
+ "shelf": "read",
+ "reviewURL": "/review/show/3238259324"
+ },
+ {
+ "id": "kca://review:goodreads/amzn1.gr.review:goodreads.v1.GLjLEKHIsU-RrmRdU73hGQ",
+ "reviewer": {
+ "id": 3504881,
+ "name": "Suzanne",
+ "url": "/user/show/3504881-suzanne",
+ "avatar": "https://i.gr-assets.com/images/S/compressed.photo.goodreads.com/users/1294105241i/3504881.jpg",
+ "isAuthor": false,
+ "reviewCount": 546,
+ "followerCount": 12
+ },
+ "rating": 2,
+ "text": "I'm very sorry to say, I could not finish this book. I REALLY wanted to be in love with this story. I even purchased the 2nd book from Thrift Books soon after I started this one. I figured they would become part of my Arthurian obsession, and I would need to own them. It started out with a great concept, and I liked the differences in the legend that I hadn't read before. I wanted to know more about the different characters, but it was so choppy, from one point of view to another back and forth, I couldn't keep track of which character I was reading about.
It just dragged on and on and on and on....it was trial after trial after trial. The characters would accomplish one win and suddenly face another trial. I felt like I was reading a bad rendition of The Odyssey. I REALLY wanted to love it. But reading became a chore, and I fell into a reading slump. The book sat in one spot for a couple weeks, and I felt it hanging over my head. I finally decided enough was enough. I wanted to be eager to read again. So I took out the bookmark, and this is going on my \"couldn't finish\" list.
I will donate the set to a Free Little Library somewhere, and I hope someone else will get some pleasure out of them.",
+ "createdAt": "Sun Oct 14 2018",
+ "updatedAt": "Fri Oct 30 2020",
+ "spoiler": false,
+ "likeCount": 2,
+ "commentCount": 2,
+ "recommendFor": null,
+ "shelf": "did-not-finish",
+ "reviewURL": "/review/show/2522667985"
+ },
+ {
+ "id": "kca://review:goodreads/amzn1.gr.review:goodreads.v1.73ge1ENF_VjVNC1CSJb8iw",
+ "reviewer": {
+ "id": 67537582,
+ "name": "Kaylee",
+ "url": "/user/show/67537582-kaylee",
+ "avatar": "https://i.gr-assets.com/images/S/compressed.photo.goodreads.com/users/1627453497i/67537582._UX200_CR0,1,200,200_.jpg",
+ "isAuthor": false,
+ "reviewCount": 793,
+ "followerCount": 33
+ },
+ "rating": 1,
+ "text": "I do have to start by saying that this is the first Arthurian legend I can recall reading. I don't think that really affected my POV but...
Extremely hard to get through from the beginning. Kept hoping it would get better but nope. Not only was the switching between third and first POV annoying but it was made worse by the main character. Did not want her voice at all. It was also hard to tell what was going on/who was speaking at times (during the third POV of course). In the beginning I went back to the and re-read things but quickly learned there was no point. Sometimes things arrived randomly and others I realized I had just spaced out because of all the needless words. You could take out half the words in this book and be all the better off for it...
Also if I had to read \"As I said...\" one more time...!
I can't remember the last time I didn't finish a book and if I hadn't had to read this because of my book club I don't think I would have finished. Says everything in a nutshell.
So much more but I'll just leave it at that. Extremely glad to be done with this book!",
+ "createdAt": "Fri Nov 09 2018",
+ "updatedAt": "Fri Oct 30 2020",
+ "spoiler": false,
+ "likeCount": 2,
+ "commentCount": 2,
+ "recommendFor": null,
+ "shelf": "read",
+ "reviewURL": "/review/show/2577596943"
+ },
+ {
+ "id": "kca://review:goodreads/amzn1.gr.review:goodreads.v1.6kd8QVi2sBrNhUslyz1aUQ",
+ "reviewer": {
+ "id": 54738977,
+ "name": "TigerLily ",
+ "url": "/user/show/54738977-tigerlily",
+ "avatar": "https://i.gr-assets.com/images/S/compressed.photo.goodreads.com/users/1460164836i/54738977.jpg",
+ "isAuthor": false,
+ "reviewCount": 133,
+ "followerCount": 3
+ },
+ "rating": 5,
+ "text": "Really enjoyable, a page Turner that I didn't want to be finished. I am looking forward to reading the 2nd book.",
+ "createdAt": "Sun Jan 10 2021",
+ "updatedAt": "Sun Mar 21 2021",
+ "spoiler": false,
+ "likeCount": 2,
+ "commentCount": 0,
+ "recommendFor": null,
+ "shelf": "read",
+ "reviewURL": "/review/show/3732026445"
+ },
+ {
+ "id": "kca://review:goodreads/amzn1.gr.review:goodreads.v1.njxZJZUHk2BVK8mcTJp2aQ",
+ "reviewer": {
+ "id": 40251914,
+ "name": "Elizabeth Sanders",
+ "url": "/user/show/40251914-elizabeth-sanders",
+ "avatar": "https://i.gr-assets.com/images/S/compressed.photo.goodreads.com/users/1530132382i/40251914.jpg",
+ "isAuthor": false,
+ "reviewCount": 38,
+ "followerCount": 0
+ },
+ "rating": 4,
+ "text": "It's an interesting retelling of Guinevere and Arthur. I loved every bit of it and can't wait to read the sequel.",
+ "createdAt": "Thu Jul 04 2019",
+ "updatedAt": "Thu Jul 11 2019",
+ "spoiler": false,
+ "likeCount": 1,
+ "commentCount": 0,
+ "recommendFor": null,
+ "shelf": "read",
+ "reviewURL": "/review/show/2871843590"
+ },
+ {
+ "id": "kca://review:goodreads/amzn1.gr.review:goodreads.v1.HegnVxYZnLTsNGxJeVV2cA",
+ "reviewer": {
+ "id": 11384547,
+ "name": "Lyndsay MacDonald",
+ "url": "/user/show/11384547-lyndsay-macdonald",
+ "avatar": "https://i.gr-assets.com/images/S/compressed.photo.goodreads.com/users/1371265927i/11384547.jpg",
+ "isAuthor": false,
+ "reviewCount": 38,
+ "followerCount": 0
+ },
+ "rating": 2,
+ "text": "This book was weird. I think it might have been tolerable if the author didn't try to weave it into the Arthurian legend (ie. if she made up original characters), but as it is I just couldn't get into it. There were some parts/scenes that I did enjoy, and others that I didn't... but overall I found it very confusing, and often found myself asking \"What is happening right now?\" and \"Why?\"
The story line flip-flopped around a lot... the characters were not wholly believable (Guinevere did NOT seem like a 13 year old girl, by any stretch of the imagination).. and I found parts of the plot silly and unrelated.
I won't be reading any of the other books in this sequence.",
+ "createdAt": "Wed Jan 22 2014",
+ "updatedAt": "Fri Aug 12 2016",
+ "spoiler": false,
+ "likeCount": 1,
+ "commentCount": 0,
+ "recommendFor": null,
+ "shelf": "read",
+ "reviewURL": "/review/show/831334767"
+ },
+ {
+ "id": "kca://review:goodreads/amzn1.gr.review:goodreads.v1.oazwOZoGnu8yIQJ_48FVMw",
+ "reviewer": {
+ "id": 27373770,
+ "name": "Andrea Krivak",
+ "url": "/user/show/27373770-andrea-krivak",
+ "avatar": "",
+ "isAuthor": false,
+ "reviewCount": 26,
+ "followerCount": 1
+ },
+ "rating": 2,
+ "text": "I think you have to really like arthurian legends to read this book. I liked it in the beginning, it started telling the story of Geunivere as a young girl and the \"what if\" this was her upbringing. There were wizards, men who turned into wolves and of course, merlin and morgana. About halfway through she meets Arthur, and then the book divides into the telling of each of their lives and they each stray into their own mystic journeys. To me, it spent too much time on these mystic journeys and by the end, i was ready to be done with this whole story. Not interesting enough ending for me to read into the sequel.",
+ "createdAt": "Mon Mar 24 2014",
+ "updatedAt": "Fri Aug 12 2016",
+ "spoiler": false,
+ "likeCount": 1,
+ "commentCount": 0,
+ "recommendFor": null,
+ "shelf": "read",
+ "reviewURL": "/review/show/890822661"
+ },
+ {
+ "id": "kca://review:goodreads/amzn1.gr.review:goodreads.v1.IxTTRac3W8gbXoHFdfTu5g",
+ "reviewer": {
+ "id": 1009204,
+ "name": "Cindy",
+ "url": "/user/show/1009204-cindy",
+ "avatar": "https://i.gr-assets.com/images/S/compressed.photo.goodreads.com/users/1228465282i/1009204._UX200_CR0,28,200,200_.jpg",
+ "isAuthor": false,
+ "reviewCount": 856,
+ "followerCount": 105
+ },
+ "rating": 1,
+ "text": "Maybe some day I'll come back to this book, but I just don't think I can read this book. I find myself wondering if I'm not following something of if this book is just beyond my reading. The writing is in a way that makes it seems like, wow we want the read to think this author is just so descriptive and great. Only it becomes confusing and bored. If you aren't familiar with the story of Guinevere then there is no point in reading it because nothing is explained and you are just sitting there wondering what the heck is going on. ",
+ "createdAt": "Wed Aug 27 2008",
+ "updatedAt": "Thu Jan 25 2018",
+ "spoiler": false,
+ "likeCount": 0,
+ "commentCount": 0,
+ "recommendFor": null,
+ "shelf": "read",
+ "reviewURL": "/review/show/31374042"
+ },
+ {
+ "id": "kca://review:goodreads/amzn1.gr.review:goodreads.v1._BHlRCbTwy-DJRq5mCTQnw",
+ "reviewer": {
+ "id": 4804714,
+ "name": "Angela",
+ "url": "/user/show/4804714-angela",
+ "avatar": "https://i.gr-assets.com/images/S/compressed.photo.goodreads.com/users/1294946776i/4804714.jpg",
+ "isAuthor": false,
+ "reviewCount": 129,
+ "followerCount": 6
+ },
+ "rating": 3,
+ "text": "I enjoyed this book up until the last third and then it got way too much involved with Arthur. It didn't quite wrap up the way I had expected and I would suggest not reading it unless you want to continue on with the series. I may read the sequel at a later time but I need a break from the acid trip like sections with Arthur.",
+ "createdAt": "Thu Mar 28 2013",
+ "updatedAt": "Tue Aug 09 2016",
+ "spoiler": false,
+ "likeCount": 1,
+ "commentCount": 0,
+ "recommendFor": null,
+ "shelf": "read",
+ "reviewURL": "/review/show/572964102"
+ },
+ {
+ "id": "kca://review:goodreads/amzn1.gr.review:goodreads.v1.P4s-Ysv7_6bKQAupxDvCwA",
+ "reviewer": {
+ "id": 4018173,
+ "name": "Misty",
+ "url": "/user/show/4018173-misty",
+ "avatar": "https://i.gr-assets.com/images/S/compressed.photo.goodreads.com/users/1286250306i/4018173._UY200_CR33,0,200,200_.jpg",
+ "isAuthor": false,
+ "reviewCount": 307,
+ "followerCount": 0
+ },
+ "rating": 2,
+ "text": "I really wanted to like this book.
About halfway through, I got bored and tried to push through it, but ultimately gave up. There was so much description and not enough happening.
Also, considering this was titled the Dragon Queen, I expected more dragons. Their lack of presence was disappointing.",
+ "createdAt": "Tue Nov 26 2013",
+ "updatedAt": "Thu Jun 23 2016",
+ "spoiler": false,
+ "likeCount": 1,
+ "commentCount": 0,
+ "recommendFor": null,
+ "shelf": "could-not-finish",
+ "reviewURL": "/review/show/774467120"
+ },
+ {
+ "id": "kca://review:goodreads/amzn1.gr.review:goodreads.v1.2E0pSS7MyztIPHdoIbH4Nw",
+ "reviewer": {
+ "id": 120860,
+ "name": "Lassarina Aoibhell",
+ "url": "/user/show/120860-lassarina-aoibhell",
+ "avatar": "",
+ "isAuthor": false,
+ "reviewCount": 487,
+ "followerCount": 5
+ },
+ "rating": 1,
+ "text": "I am DNFing this book on page 84 and we're going to go on a wild ride about why.
NB: If you are a scholar of Arthuriana, history, religion, or....well, almost anything, you might want to put down your drink. I'm not responsible for the state of your monitor.
We begin with the cover copy: Daughter of a mighty pagan queen, Guinevere has become a threat to her people and a prize to the dreaded sorcerer Merlin. Sent into hiding, she grows up under the protection of a shapeshifting man-wolf and an ornery Druid. But even on the remote coast of Scotland, where dragons feed and watch over her, she is not safe. Merlin knows the young beauty's destiny--and he will stop at nothing to prevent what has been foretold...
I also need to strongly highlight one of the blurbs. Like, really strongly. \"In addition to being exceptionally well written, [The Dragon Queen] has enormous integrity: All of the stories--of Arthur's parentage, of Merlin, of the characters' daily struggles--are well grounded in the history, politics, and religions of the time.....This is a trilogy to watch.\" --San Jose Mercury News
I want you to look at that quote carefully, and then come on a ride with me.
I will acknowledge up front my own reading comprehension fail, because I had forgotten that the shapeshifter was actually in the blurb and was yelling about \"what the fuck.\". I'm still saying \"what the fuck,\" just about plenty of other things.
Let's start with: It takes until page 48 for Guinevere to show up in any real sense. 500 page book; the main character doesn't actually appear until 10% in. Don't like that. Instead we are following around Dugald, the druid, and Maeniel, the shapeshifter. He's allegedly from Gaul. We begin at Tintagel in the year 470 CE, where Merlin and some of his cronies murder Vortigen. Some kind of magical snake is involved. (Aside: absolutely no problem with Merlin as the villain, particularly in anything allegedly centering the women of this tale.) Maeniel the shifter teleports??? out of the carnage, finds himself a she-wolf who has recently given birth, and shacks up with her. They encounter Dugald and the toddler Guinevere, who is currently disguised as a boy. For some reason, although everyone complains about disliking the Romans, we re-enact the founding of Rome, with Maeniel's mate suckling little Guinevere. Why does the she-wolf agree to this? Who can say. Dugald, meanwhile, talks about the influence of St. Patrick. Yes, that one. \"We don't call ourselves [druids] any longer. Not since Patrick came to Ireland bearing Christ's message.\"
....ten pages ago we called Merlin a chief druid. Also. It's the year 470. We don't have exact dates of the saint's life, but we know it was roughly in that century. More importantly, it was the Romans who banned druidic practices, fucking 400 years earlier (Augustus had no chill.) So there is no way that Patrick's actions in Ireland are affecting the order in England. Remember that blurb I highlighted? HMMMM. ALSO WHY ARE YOU, A GUY IN CORNWALL, TALKING ABOUT PATRICK. why. what is even your deal.
Also also, in this variation of the story (?), apparently, Dugald had a philosophical disagreement with Merlin and \"lost,\" so he was expected by the rules of his order to commit suicide because he lost.
Me: I do not remember reading this in any of the books I consumed during my obsessive period about Celtic histories and cultures and archaeology. (Wiki and checking with knowledgeable friends suggests I am not alone in not remembering this.)
If nothing else, that seems like a giant fucking waste of a 20 year apprenticeship, man.
(At this point I also stopped to verify my memory that Dugald and Maeniel do not appear in other Arthurian sources. I seem to be correct. And like, yeah, we add characters to stories all the time, I'm just. why are you making me deal with men in a book about Guinevere!)
There's also a point where young Guinevere, Dugald, and Maeniel are involved in helping to fight off a ship of slavers, which conveniently results in there being a woman to join the household and teach Guinevere How To Be A Girl. I should specify that the captain of said slaver's ship was apparently named Cymry. Why is he literally named \"Welsh\"? fuck if I know.
There's also a particular kind of ambient misogyny that I find tiresome, and like, I know we're in the fifth century and the Romans were assholes, and also I am aware that Celtic cultures were not paragons of equality and virtue, but the available evidence both of Romans being bitchy about how ass-backward Celtic societies were (omg they let their women do things!!!!) and the remnants of brehon law allocating specific property to women after divorce and categorizing a type of marriage as \"she married down and gets to keep all her stuff\" [wildly paraphrased] I'm just like
what. what are you doing, ma'am
and once again I'm going to refer back to that blurb. About being well grounded in the history, politics, and religions of the time.
I do not recommend actually reading this unless you want to play a drinking game, in which case, may I suggest that the drink you use should perhaps be water?",
+ "createdAt": "Thu Apr 09 2026",
+ "updatedAt": "Thu Apr 09 2026",
+ "spoiler": false,
+ "likeCount": 0,
+ "commentCount": 0,
+ "recommendFor": null,
+ "shelf": "did-not-finish",
+ "reviewURL": "/review/show/1761732484"
+ },
+ {
+ "id": "kca://review:goodreads/amzn1.gr.review:goodreads.v1.9X0QSJx7BxHSTHSctKMkwg",
+ "reviewer": {
+ "id": 11348002,
+ "name": "Laural",
+ "url": "/user/show/11348002-laural",
+ "avatar": "https://i.gr-assets.com/images/S/compressed.photo.goodreads.com/users/1349329030i/11348002._UX200_CR0,5,200,200_.jpg",
+ "isAuthor": false,
+ "reviewCount": 36,
+ "followerCount": 5
+ },
+ "rating": 2,
+ "text": "I got about halfway through this book. Someday I'll give it another chance, or maybe try to figure out what exactly the author did wrong. A sword weilding Gweneviere? It could have been great. I thought it would be after reading the description on the back of the gorgeous cover. ",
+ "createdAt": "Thu Oct 04 2012",
+ "updatedAt": "Thu Jun 23 2016",
+ "spoiler": false,
+ "likeCount": 1,
+ "commentCount": 0,
+ "recommendFor": null,
+ "shelf": "read",
+ "reviewURL": "/review/show/427412066"
+ },
+ {
+ "id": "kca://review:goodreads/amzn1.gr.review:goodreads.v1.p0mSKsHft_BTv2vScLHOsQ",
+ "reviewer": {
+ "id": 1250008,
+ "name": "Giuila",
+ "url": "/user/show/1250008-giuila",
+ "avatar": "https://i.gr-assets.com/images/S/compressed.photo.goodreads.com/users/1244141968i/1250008._UY200_CR2,0,200,200_.jpg",
+ "isAuthor": false,
+ "reviewCount": 61,
+ "followerCount": 0
+ },
+ "rating": 0,
+ "text": "... t read",
+ "createdAt": "Thu Jul 17 2008",
+ "updatedAt": "Fri Mar 24 2017",
+ "spoiler": false,
+ "likeCount": 1,
+ "commentCount": 0,
+ "recommendFor": null,
+ "shelf": "to-read",
+ "reviewURL": "/review/show/27520982"
+ },
+ {
+ "id": "kca://review:goodreads/amzn1.gr.review:goodreads.v1.QMO0-GkHiX0UEYcsOaO7lw",
+ "reviewer": {
+ "id": 1431136,
+ "name": "Matt",
+ "url": "/user/show/1431136-matt",
+ "avatar": "https://i.gr-assets.com/images/S/compressed.photo.goodreads.com/users/1218856549i/1431136._UY200_CR25,0,200,200_.jpg",
+ "isAuthor": false,
+ "reviewCount": 6,
+ "followerCount": 0
+ },
+ "rating": 5,
+ "text": "an overall awesome book",
+ "createdAt": "Sat Aug 16 2008",
+ "updatedAt": "Fri Mar 24 2017",
+ "spoiler": false,
+ "likeCount": 1,
+ "commentCount": 0,
+ "recommendFor": null,
+ "shelf": "read",
+ "reviewURL": "/review/show/30279062"
+ },
+ {
+ "id": "kca://review:goodreads/amzn1.gr.review:goodreads.v1.qOdgrbVKU-XMQR5DSbHfDQ",
+ "reviewer": {
+ "id": 16979552,
+ "name": "Sarah Garner",
+ "url": "/user/show/16979552-sarah-garner",
+ "avatar": "https://i.gr-assets.com/images/S/compressed.photo.goodreads.com/users/1408284741i/16979552._UX200_CR0,53,200,200_.jpg",
+ "isAuthor": false,
+ "reviewCount": 84,
+ "followerCount": 18
+ },
+ "rating": 2,
+ "text": "I struggled with this book and I couldn't finish it, although it was a great idea, I didn't love this book.",
+ "createdAt": "Sat Sep 20 2014",
+ "updatedAt": "Thu Aug 20 2020",
+ "spoiler": false,
+ "likeCount": 1,
+ "commentCount": 0,
+ "recommendFor": null,
+ "shelf": "read",
+ "reviewURL": "/review/show/1059961768"
+ },
+ {
+ "id": "kca://review:goodreads/amzn1.gr.review:goodreads.v1.JjlmKD1eCF28HlpY4KmCGw",
+ "reviewer": {
+ "id": 34744007,
+ "name": "Michael D.",
+ "url": "/user/show/34744007-michael-d",
+ "avatar": "",
+ "isAuthor": false,
+ "reviewCount": 81,
+ "followerCount": 1
+ },
+ "rating": 2,
+ "text": "I had this book about 20 years ago, tried to read it, but I just couldn't get into it. I got another copy used and gave it another try. While not as bad as my first experience in the first 20 pages, I found in the ensuing pages that the narrative is quite disjointed, and timelines are fluid and unclear to me. Perhaps that was Borchardt's intent, but I was confused by it. Additionally, it seems that characters are magically transported to strange places without any transition that I could discern. This narrative was hard to follow.
The book begins in a scene in 470 in Tintigal (not Tintagil, the famous castle in Cornwall where Arthur was conceived and born in the traditional romance). Ostensibly, the main character is a wolfman (a wolf who changes into a man when he chooses to) named Maeniel, and he is a friend to the British king Vortigen, who is not the villain he is normally portrayed as in history and the romances. Merlin shows up with a contingent of Saxons, and a battle ensues in which Vortigen is slain and Maeniel is knocked unconscious by an unknown cudgel. In the next scene, he wakes up on a hillside far away, presumably the far north, near Hadrian's Wall, and he becomes the mate of a she-wolf, who bears him a son, Black Leg (who will become Lancelot in the second novel The Raven Warrior). Sometime later, we find that 70 years have passed since Vortigen's death. Oddly, there is a baby girl in the nearby village, Guenevere (Borchardt spells her name differently depending on who is addressing her), who is one of the major characters of this series, the eponymous destined Dragon Queen. When Guenevere is 15, we are then told that 100 years have passed since Vortigen died. Confusion abounds.
This story had occasional high points, with modern fantastical treatments of the transformation duels, but I really had a hard time following the flow of Borchardt's narrative. I am a pretty astute reader, so I find it difficult to believe this was my fault. Borchardt seemed to be playing coy with the unfolding of events, and that was annoying. She was the sister of the better-known Anne Rice, author of the Vampire Chronicles, so I tried hard to give this a chance.
Borchardt intended to make this a trilogy, but the passed away in 2007 before the third book in the trilogy could be published. I don't know if anyone picked up her notes with the intent to complete her work, and I can't imagine that there was a demand for it. I do intend to slog through The Raven Warrior, as it is my rule to read books through and evaluate them honestly. If they're bad, I point out why. The lack of comprehensible transitions in time and space are my primary complaint with this novel. I hope that The Raven Warrior is more cognizant of walking me through how characters get from Point A to Point B than this one was. ",
+ "createdAt": "Thu Nov 09 2023",
+ "updatedAt": "Thu Nov 09 2023",
+ "spoiler": false,
+ "likeCount": 0,
+ "commentCount": 0,
+ "recommendFor": null,
+ "shelf": "read",
+ "reviewURL": "/review/show/5944805120"
+ },
+ {
+ "id": "kca://review:goodreads/amzn1.gr.review:goodreads.v2.2025072400.a7efbaf3-dd61-4567-b4e1-d3f344fabb0c",
+ "reviewer": {
+ "id": 42506425,
+ "name": "Michelle M",
+ "url": "/user/show/42506425-michelle-m",
+ "avatar": "https://i.gr-assets.com/images/S/compressed.photo.goodreads.com/users/1715615833i/42506425._UX200_CR0,0,200,200_.jpg",
+ "isAuthor": false,
+ "reviewCount": 380,
+ "followerCount": 3
+ },
+ "rating": 5,
+ "text": "Honestly? 5/6 or 4.75/6 I really loved this book! It's hard to describe what exactly I loved about it though lol. It had a very ethereal quality to it... The magic in this world is just a fact of life, some peple have it, while others don't, and yet, it didn't feel fantastical. Everyone knows about magic and most peoples still believe in the gods and things even though \"The Church\" (christianity) is present on the British Isles.
Borchardt grounds the story in the time right after Rome leaves the Isles, though they are still very much an influence on the peoples. It was a very mystical vibe to the whole story, kind of like there is a shroud of fog that is the magic of the world. IDK, like I said, hard to describe lol.
I loved Guinevere, and I liked how different characters referred to her by various spellings/pronouciations thoughout the story, as if they were speaking different languages. It is a very different take on the Arthur/Guinevere story, and very much an action oriented one, with Guinevere being the focus of the action and not just a passive bystander.
I though the portrayal of the other major characters interesting, and they were given roles that they are not usual given in the story. Arthur is \"good\", Merlin and Ingrane are the \"evil\" ones, Guinevere is strong willed and knows what she wants and sets out to get it. She doesn't seem like the type of person who will betray Arthur with Lancelot, but we'll just have to find out! (I hope she doesn't). The portrayal of these characters (and other named characters) made me do some research to try to get a better picture of if they had ever been portayed like this before. My search was unsucessful lol, as they were very diffefrent from \"traditional\" stories, but I haven't really read many retellings, so I cant be sure if they are exclusive to this story.
My only compaint with this book is that I found out it was supposed to be a trilogy!!!! And YET there are only two books! T.T *cries* I know the author can't help that she passed away, but book 2 came out in 2003 and she passed in 2007! That seems like a decent amount of time to finish the third book (I'm being very ungrateful, I know I am, I just lament what never was). Also apparently Anne Rice of vampire fame is Borchardt's sister?!?!?! She doesn't have like an early draft of her sister's just laying around??? T.T
I will def be reading the next one! (this one took me so long because I paused to participate in a read a thon and then got distracted by other books lol and then I got busy with work so I didn't have as much time to read)",
+ "createdAt": "Thu Jul 24 2025",
+ "updatedAt": "Thu Jul 24 2025",
+ "spoiler": false,
+ "likeCount": 0,
+ "commentCount": 0,
+ "recommendFor": null,
+ "shelf": "read",
+ "reviewURL": "/review/show/7714101862"
+ },
+ {
+ "id": "kca://review:goodreads/amzn1.gr.review:goodreads.v1.HXGbQg0020aQJjD9G6pW2A",
+ "reviewer": {
+ "id": 163241100,
+ "name": "Alex",
+ "url": "/user/show/163241100-alex",
+ "avatar": "https://i.gr-assets.com/images/S/compressed.photo.goodreads.com/users/1763011908i/163241100._UY200_CR11,0,200,200_.jpg",
+ "isAuthor": false,
+ "reviewCount": 98,
+ "followerCount": 0
+ },
+ "rating": 2,
+ "text": "I enjoyed Guinevere’s character early on in the book. It was interesting watching her being raised alongside her foster brother, Black Leg, by a wolf shifter (Maniel) and a she-wolf. I do appreciate her relationship with the She Wolf, who Guinevere calls Mother. The thing that really kept me going through this book was Guinevere’s interactions with the dragons of the land and her connection to the land. I was also intrigued by some of the same conversations about religion in this book since Guinevere is raised in nature and has regular interactions with a Druid and some interactions with Christiany. All this being said I struggled with the writing. There are huge chunks of it that are confusing since it’s not always clear who is being talked about when he, she or they are mentioned considering there might be multiple people mentioned previously in the scene. Transitions between moments are also rough. I was questioning when things changed and what was being talked about. The long descriptions of things really didn’t help either and me zoning out as I read. It was also a bit rough to read this because of how things were worded/certain sciences being really uncomfortable for seemingly no reason out of nowhere. The dynamics between Igraine and Merlin’s relationship being one them. Scenes with them were sexual with zero warning and it wasn’t just limited to them. It happened a lot with various characters. Things were also made pretty uncomfortable with minors even if it was a scene that wasn’t intended to uncomfortably sexual, it was made that way by directly saying things like they “were far too young for even a dream of passion.” With how confusing the writing was I couldn’t even tell if there was a reason for why these sort of scenes were written in detail outside of making the audience feel uncomfortable and actively dislike the characters who are at fault for these behaviors.",
+ "createdAt": "Sun May 24 2026",
+ "updatedAt": "Sun May 24 2026",
+ "spoiler": false,
+ "likeCount": 0,
+ "commentCount": 0,
+ "recommendFor": null,
+ "shelf": "read",
+ "reviewURL": "/review/show/5900548383"
+ },
+ {
+ "id": "kca://review:goodreads/amzn1.gr.review:goodreads.v1.VoNZbtbhrTyL1Fhg43jpGw",
+ "reviewer": {
+ "id": 42064438,
+ "name": "Briana",
+ "url": "/user/show/42064438-briana",
+ "avatar": "https://i.gr-assets.com/images/S/compressed.photo.goodreads.com/users/1715717411i/42064438._UY200_CR5,0,200,200_.jpg",
+ "isAuthor": false,
+ "reviewCount": 161,
+ "followerCount": 1
+ },
+ "rating": 2,
+ "text": "This book was not for me. I had high hopes, the summary sounded interesting. It took me much longer than usual to finish this book as I struggled to enjoy it and I will not be picking up the second installment. I almost DNF'd this book several times but decided to stick with it.
The first half of the book felt like it was the author's freewriting development of the book, which she never bothered to go back and actually edit into a cohesive and understandable story. It was like being is some kind of dream where everything was hazy and only loosely connected. Sometimes you ended up in an entirely different location or time without even realizing it. It was also almost impossible to understand who was being discussed as the author tends to avoid using names and simply use pronouns leaving the reader to try and figure out (usually without success) who the \"she\" is that is being discussed. It was also very interesting that the author (or editor) chose to spell Guinevere's name differently throughout the book.
The second half was a little better and is the only reason I didn't give this book a one star. I don't mind Merlin as the antagonist as some other reviewers do and I found his character interesting. However, I did not really like the relationship between Arthur and Guinevere. I'm not sure how I am supposed to be convinced that this was some fated love story when they met for all of maybe an hour total. It seemed more insta-lust than anything else and there was no relationship development at all after that.
Overall I spent my time either being very confused and a little bored or kind of confused and mildly interested.",
+ "createdAt": "Thu Aug 15 2024",
+ "updatedAt": "Thu Aug 15 2024",
+ "spoiler": false,
+ "likeCount": 0,
+ "commentCount": 0,
+ "recommendFor": null,
+ "shelf": "read",
+ "reviewURL": "/review/show/6728900786"
+ },
+ {
+ "id": "kca://review:goodreads/amzn1.gr.review:goodreads.v1.IoZW54HX8Qad9w5IgbQB9A",
+ "reviewer": {
+ "id": 6424389,
+ "name": "Joy Pouros",
+ "url": "/user/show/6424389-joy-pouros",
+ "avatar": "https://i.gr-assets.com/images/S/compressed.photo.goodreads.com/users/1567889312i/6424389._UY200_CR47,0,200,200_.jpg",
+ "isAuthor": false,
+ "reviewCount": 125,
+ "followerCount": 2
+ },
+ "rating": 3,
+ "text": "2.5 stars.
I REALLY like the character of Guinevere, but there were other parts of the story I didn't enjoy.
The writing style can be very winding and even abstract in some ways. There are whole parts you can skim without missing much. There aren't a ton of people, but enough to get them confused, especially a couple with similar names.
I'm always very frustrated with stories that try to have strong female characters but still have an overly sexually charged world. There are so many wonderfully creative parts of this world and then we default back to several tired tropes. There's just no need.
Some chapters are 30 pages, some are 5. It doesn't appear based on POV or time, so why is this so inconsistent?
And finally... I understand this is the first book of the trilogy, but this book just felt like a long prologue. Other series the first book is still a worthwhile read and entertaining story. This is more of an origin story before you fully appreciate the main story. So there are very mixed feelings about continuing the series because while I love Guinevere, there's an awful lot to overlook. ",
+ "createdAt": "Tue Aug 30 2022",
+ "updatedAt": "Tue Aug 30 2022",
+ "spoiler": false,
+ "likeCount": 0,
+ "commentCount": 0,
+ "recommendFor": null,
+ "shelf": "read",
+ "reviewURL": "/review/show/4955253716"
+ },
+ {
+ "id": "kca://review:goodreads/amzn1.gr.review:goodreads.v1.ahVbGwMWksFfbjRS9c6Vbg",
+ "reviewer": {
+ "id": 29405146,
+ "name": "Anita",
+ "url": "/user/show/29405146-anita",
+ "avatar": "https://i.gr-assets.com/images/S/compressed.photo.goodreads.com/users/1407269712i/29405146._CR0,33,200,200_.jpg",
+ "isAuthor": false,
+ "reviewCount": 614,
+ "followerCount": 3
+ },
+ "rating": 1,
+ "text": "First of all, I love Arthurian legends. Second, I always finish books, constantly hopeful that the story will improve, but I must confess that I skimmed this book to the end. I just cannot read any more! This story has no cohesion, no main narrator and skips from one location to another mid-chapter. Plus there are men suddenly shapeshifting into wolves, and back again; no reasons given; no context for the intrigue and killings taking place; and a young girl child, supposedly Guinevere of Arthurian legend, being suckled by a she-wolf after being kidnapped. Imaginative description is great in a fantasy story; confusion is not. This book is one long puzzle - a maze with apparently no direction, interspersed with some flowery descriptions of sunsets and misty mornings; visions of ravens and dragons (fishing); and a cast of hundreds of unrelated and irrelevant characters. Reading this book feels akin to experiencing one of those dreams in which you are lost and desperately trying to find your way back home. Need I add that I did not enjoy this book, and will not be reading the sequel?",
+ "createdAt": "Fri Oct 30 2020",
+ "updatedAt": "Fri Oct 30 2020",
+ "spoiler": true,
+ "likeCount": 0,
+ "commentCount": 0,
+ "recommendFor": null,
+ "shelf": "read",
+ "reviewURL": "/review/show/3618732305"
+ },
+ {
+ "id": "kca://review:goodreads/amzn1.gr.review:goodreads.v1.zo7-0bYwOUS0LlPXyqeuXQ",
+ "reviewer": {
+ "id": 3198878,
+ "name": "Stevie",
+ "url": "/user/show/3198878-stevie",
+ "avatar": "https://i.gr-assets.com/images/S/compressed.photo.goodreads.com/users/1560478233i/3198878._UX200_CR0,33,200,200_.jpg",
+ "isAuthor": false,
+ "reviewCount": 71,
+ "followerCount": 0
+ },
+ "rating": 2,
+ "text": "Rating this book was a challenge. It was an impulse grab. I suspected it would be wonderfully terrible or terribly wonderful and there was only one way to find out.
What I liked: Arthurian legend from Guin's point of view. Cool. That awkward time period in history when they were both Christian but still observed many pagan rites. It had all the names I knew, and some of the tropes I knew: Vortigen, the witch in the well, so on and so forth from various movies I've seen.
What I disliked: the writing. Some of the dialogue wasn't needed. The descriptions could be too much in places and entirely lacking in others. Who is speaking? What do they look like? They were a human in a tower and now they are a fish? When? How? Transitions don't exist. This makes it hard to follow.
It was a wild ride, and I hung on until the end because I was curious as to how it would turn out. Turns out there's a sequel...I don't know that I have that in me...",
+ "createdAt": "Sat Mar 21 2020",
+ "updatedAt": "Sat Mar 21 2020",
+ "spoiler": false,
+ "likeCount": 0,
+ "commentCount": 0,
+ "recommendFor": null,
+ "shelf": "read",
+ "reviewURL": "/review/show/3240571912"
+ },
+ {
+ "id": "kca://review:goodreads/amzn1.gr.review:goodreads.v1.E7XotAu3DvsavlUw1u62eA",
+ "reviewer": {
+ "id": 107938758,
+ "name": "Danai",
+ "url": "/user/show/107938758-danai",
+ "avatar": "https://i.gr-assets.com/images/S/compressed.photo.goodreads.com/users/1705239736i/107938758._UX200_CR0,33,200,200_.jpg",
+ "isAuthor": false,
+ "reviewCount": 133,
+ "followerCount": 0
+ },
+ "rating": 3,
+ "text": "I didn't love it, nor did I hate it.
Everything was magical, mystical, and mysterious; Borchardt painted an imaginative world with interesting and diverse characters. She evidently has a deep knowledge of the British Isles, its history, and its lore.
But the writing was, at times, completely chaotic and confusing. As if someone was just spilling their string of thoughts without sufficient context or details - or too many of the wrong details. More than once, I found myself confused by abrupt changes in the situation, only to learn - through narration - that a memory was being shared.
This book didn't capture me nor engross me by any means, but it did beckon me to come back and finish it. If I come across the next book in the series, I will read it simply out of curiosity - what happens next for our heroes and villains? But I won't go hunting for a copy of it anytime soon...",
+ "createdAt": "Thu Jan 11 2024",
+ "updatedAt": "Thu Jan 11 2024",
+ "spoiler": false,
+ "likeCount": 0,
+ "commentCount": 0,
+ "recommendFor": null,
+ "shelf": "read",
+ "reviewURL": "/review/show/5638508977"
+ }
+ ],
+ "lastScraped": "2026-10-28T18:38:19.808Z"
+}
\ No newline at end of file
diff --git a/internal/datasource/biblioreads/fixtures/search-books.json b/internal/datasource/biblioreads/fixtures/search-books.json
new file mode 100644
index 00000000..3c3de16c
--- /dev/null
+++ b/internal/datasource/biblioreads/fixtures/search-books.json
@@ -0,0 +1,19 @@
+{
+ "status": "Received",
+ "source": "https://github.com/nesaku/biblioreads",
+ "apiURL": "https://www.goodreads.com/book/auto_complete?format=json&q=La%20Reina%20Drag%C3%B3n%20Alice%20Borchardt",
+ "searchType": "books",
+ "numberOfResults": "1 results",
+ "result": [
+ {
+ "id": 1,
+ "cover": "https://i.gr-assets.com/images/S/compressed.photo.goodreads.com/books/1332599049i/12207444._SX50_.jpg",
+ "title": "La Reina Dragon (La leyenda de Guinevere, #1)",
+ "bookURL": "/book/show/12207444-la-reina-dragon",
+ "author": "Alice Borchardt",
+ "authorURL": "/author/show/151155.Alice_Borchardt",
+ "rating": "3.82"
+ }
+ ],
+ "lastScraped": "2026-10-28T17:45:31.333Z"
+}
diff --git a/internal/datasource/biblioreads/model.go b/internal/datasource/biblioreads/model.go
new file mode 100644
index 00000000..55de7137
--- /dev/null
+++ b/internal/datasource/biblioreads/model.go
@@ -0,0 +1,108 @@
+package biblioreads
+
+type SearchBooksResponse struct {
+ Status string `json:"status"`
+ Source string `json:"source"`
+ APIURL string `json:"apiURL"`
+ SearchType string `json:"searchType"`
+ NumberOfResults string `json:"numberOfResults"`
+ Result []SearchResult `json:"result"`
+ LastScraped string `json:"lastScraped"`
+}
+
+type SearchResult struct {
+ ID int `json:"id"`
+ Cover string `json:"cover"`
+ Title string `json:"title"`
+ BookURL string `json:"bookURL"`
+ Author string `json:"author"`
+ AuthorURL string `json:"authorURL"`
+ Rating string `json:"rating"`
+}
+
+type BookResponse struct {
+ Status string `json:"status"`
+ StatusCode int `json:"statusCode"`
+ Source string `json:"source"`
+ ScrapeURL string `json:"scrapeURL"`
+ LegacyBookID int `json:"legacyBookID"`
+ BookID string `json:"bookID"`
+ ResourceID string `json:"resourceID"`
+ Cover string `json:"cover"`
+ Series string `json:"series"`
+ SeriesURL string `json:"seriesURL"`
+ SeriesPosition string `json:"seriesPosition"`
+ WorkURL string `json:"workURL"`
+ Title string `json:"title"`
+ Author []BookAuthor `json:"author"`
+ Rating string `json:"rating"`
+ RatingCount string `json:"ratingCount"`
+ ReviewsCount string `json:"reviewsCount"`
+ Desc string `json:"desc"`
+ Genres []string `json:"genres"`
+ BookEdition string `json:"bookEdition"`
+ PublishDate string `json:"publishDate"`
+ Publisher string `json:"publisher"`
+ Language string `json:"language"`
+ ASIN string `json:"asin"`
+ ISBN string `json:"isbn"`
+ ISBN13 string `json:"isbn13"`
+ ReviewBreakdown ReviewBreakdown `json:"reviewBreakdown"`
+ Quotes string `json:"quotes"`
+ QuotesURL string `json:"quotesURL"`
+ Questions string `json:"questions"`
+ QuestionsURL string `json:"questionsURL"`
+ LastScraped string `json:"lastScraped"`
+}
+
+type BookAuthor struct {
+ ID int `json:"id"`
+ Name string `json:"name"`
+ Role string `json:"role"`
+ URL string `json:"url"`
+}
+
+type ReviewBreakdown struct {
+ Rating1 string `json:"rating1"`
+ Rating2 string `json:"rating2"`
+ Rating3 string `json:"rating3"`
+ Rating4 string `json:"rating4"`
+ Rating5 string `json:"rating5"`
+}
+
+type ReviewsResponse struct {
+ Status string `json:"status"`
+ StatusCode int `json:"statusCode"`
+ Source string `json:"source"`
+ ScrapeURL string `json:"scrapeURL"`
+ TotalCount int `json:"totalCount"`
+ NextPageToken string `json:"nextPageToken"`
+ PrevPageToken string `json:"prevPageToken"`
+ Reviews []Review `json:"reviews"`
+ LastScraped string `json:"lastScraped"`
+}
+
+type Review struct {
+ ID string `json:"id"`
+ Reviewer Reviewer `json:"reviewer"`
+ Rating int `json:"rating"`
+ Text string `json:"text"`
+ CreatedAt string `json:"createdAt"`
+ UpdatedAt string `json:"updatedAt"`
+ Spoiler bool `json:"spoiler"`
+ LikeCount int `json:"likeCount"`
+ CommentCount int `json:"commentCount"`
+ RecommendFor *string `json:"recommendFor"`
+ Shelf string `json:"shelf"`
+ ReviewURL string `json:"reviewURL"`
+}
+
+type Reviewer struct {
+ ID int `json:"id"`
+ Name string `json:"name"`
+ URL string `json:"url"`
+ Avatar string `json:"avatar"`
+ IsAuthor bool `json:"isAuthor"`
+ ReviewCount int `json:"reviewCount"`
+ FollowerCount int `json:"followerCount"`
+}
diff --git a/internal/datasource/httpjson/httpjson.go b/internal/datasource/httpjson/httpjson.go
new file mode 100644
index 00000000..100c931d
--- /dev/null
+++ b/internal/datasource/httpjson/httpjson.go
@@ -0,0 +1,72 @@
+// Package httpjson contains helpers shared by the datasource clients that consume JSON HTTP APIs.
+package httpjson
+
+import (
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "strconv"
+ "time"
+)
+
+const (
+ UserAgent = "Mozilla/5.0 (compatible; coreander/1.0; +https://github.com/svera/coreander)"
+
+ // DefaultRetryAfter is used when a Retry-After header is missing or invalid.
+ DefaultRetryAfter = 60 * time.Second
+)
+
+type Response struct {
+ StatusCode int
+ Header http.Header
+ Body []byte
+}
+
+// Do sends req with the default headers and reads the whole response body.
+func Do(client *http.Client, req *http.Request) (Response, error) {
+ req.Header.Set("User-Agent", UserAgent)
+ req.Header.Set("Accept", "application/json")
+
+ resp, err := client.Do(req)
+ if err != nil {
+ return Response{}, err
+ }
+ defer resp.Body.Close()
+
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return Response{}, err
+ }
+ return Response{StatusCode: resp.StatusCode, Header: resp.Header, Body: body}, nil
+}
+
+// Decode unmarshals the response body into dest, returning an error if the status code is not 200.
+func (r Response) Decode(dest any) error {
+ if r.StatusCode != http.StatusOK {
+ return fmt.Errorf("request failed with status code %d: %s", r.StatusCode, r.Body)
+ }
+ return json.Unmarshal(r.Body, dest)
+}
+
+// ParseRetryAfter returns the wait time specified by a Retry-After header value, which can be
+// either a number of seconds or an HTTP date. DefaultRetryAfter is returned if it can't be used.
+func ParseRetryAfter(header string) time.Duration {
+ if header == "" {
+ return DefaultRetryAfter
+ }
+ if seconds, err := strconv.Atoi(header); err == nil {
+ if seconds <= 0 {
+ return DefaultRetryAfter
+ }
+ return time.Duration(seconds) * time.Second
+ }
+ if retryTime, err := http.ParseTime(header); err == nil {
+ wait := time.Until(retryTime)
+ if wait <= 0 {
+ return DefaultRetryAfter
+ }
+ return wait
+ }
+ return DefaultRetryAfter
+}
diff --git a/internal/datasource/httpjson/httpjson_test.go b/internal/datasource/httpjson/httpjson_test.go
new file mode 100644
index 00000000..d44c33ed
--- /dev/null
+++ b/internal/datasource/httpjson/httpjson_test.go
@@ -0,0 +1,77 @@
+package httpjson_test
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "testing"
+ "time"
+
+ "github.com/svera/coreander/v5/internal/datasource/httpjson"
+)
+
+func TestParseRetryAfter(t *testing.T) {
+ t.Run("seconds", func(t *testing.T) {
+ if got := httpjson.ParseRetryAfter("30"); got != 30*time.Second {
+ t.Fatalf("expected 30s, got %s", got)
+ }
+ })
+
+ t.Run("http date", func(t *testing.T) {
+ retryTime := time.Now().Add(45 * time.Second).UTC()
+ got := httpjson.ParseRetryAfter(retryTime.Format(http.TimeFormat))
+ if got < 44*time.Second || got > 46*time.Second {
+ t.Fatalf("expected about 45s, got %s", got)
+ }
+ })
+
+ t.Run("missing header uses default", func(t *testing.T) {
+ if got := httpjson.ParseRetryAfter(""); got != httpjson.DefaultRetryAfter {
+ t.Fatalf("expected default %s, got %s", httpjson.DefaultRetryAfter, got)
+ }
+ })
+
+ t.Run("invalid header uses default", func(t *testing.T) {
+ for _, header := range []string{"-5", "0", "soon"} {
+ if got := httpjson.ParseRetryAfter(header); got != httpjson.DefaultRetryAfter {
+ t.Fatalf("expected default %s for %q, got %s", httpjson.DefaultRetryAfter, header, got)
+ }
+ }
+ })
+}
+
+func TestDo(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Header.Get("User-Agent") != httpjson.UserAgent {
+ t.Errorf("unexpected User-Agent %q", r.Header.Get("User-Agent"))
+ }
+ if r.Header.Get("Accept") != "application/json" {
+ t.Errorf("unexpected Accept %q", r.Header.Get("Accept"))
+ }
+ w.Header().Set("Retry-After", "5")
+ w.WriteHeader(http.StatusTeapot)
+ _, _ = w.Write([]byte(`{"a":1}`))
+ }))
+ defer server.Close()
+
+ req, _ := http.NewRequest(http.MethodGet, server.URL, nil)
+ resp, err := httpjson.Do(server.Client(), req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if resp.StatusCode != http.StatusTeapot || resp.Header.Get("Retry-After") != "5" || string(resp.Body) != `{"a":1}` {
+ t.Errorf("unexpected response %+v", resp)
+ }
+}
+
+func TestDecode(t *testing.T) {
+ var dest struct{ A int }
+ if err := (httpjson.Response{StatusCode: http.StatusOK, Body: []byte(`{"a":1}`)}).Decode(&dest); err != nil || dest.A != 1 {
+ t.Errorf("expected A=1 without error, got %d, %v", dest.A, err)
+ }
+ if err := (httpjson.Response{StatusCode: http.StatusNotFound, Body: []byte(`{}`)}).Decode(&dest); err == nil {
+ t.Error("expected error on non-200 status")
+ }
+ if err := (httpjson.Response{StatusCode: http.StatusOK, Body: []byte(``)}).Decode(&dest); err == nil {
+ t.Error("expected error on invalid JSON")
+ }
+}
diff --git a/internal/datasource/wikidata/httpclient.go b/internal/datasource/wikidata/httpclient.go
index 18d90a0f..f2e0b5f6 100644
--- a/internal/datasource/wikidata/httpclient.go
+++ b/internal/datasource/wikidata/httpclient.go
@@ -1,17 +1,13 @@
package wikidata
import (
- "encoding/json"
- "fmt"
- "io"
"log"
"net/http"
- "strconv"
"sync"
"time"
-)
-const defaultRetryAfter = 60 * time.Second
+ "github.com/svera/coreander/v5/internal/datasource/httpjson"
+)
var apiHTTPClient = newRateLimitedHTTPClient()
@@ -35,33 +31,18 @@ func (c *rateLimitedHTTPClient) getJSON(url string, dest any) error {
if err != nil {
return err
}
- for key, value := range wikidataAPIHeaders() {
- req.Header.Set(key, value)
- }
- resp, err := c.client.Do(req)
+ resp, err := httpjson.Do(c.client, req)
if err != nil {
return err
}
if resp.StatusCode == http.StatusTooManyRequests {
- _ = resp.Body.Close()
c.waitUntil(resp.Header.Get("Retry-After"))
continue
}
- if resp.StatusCode != http.StatusOK {
- body, _ := io.ReadAll(resp.Body)
- _ = resp.Body.Close()
- return fmt.Errorf("request failed with status code %d: %s", resp.StatusCode, body)
- }
-
- body, err := io.ReadAll(resp.Body)
- _ = resp.Body.Close()
- if err != nil {
- return err
- }
- return json.Unmarshal(body, dest)
+ return resp.Decode(dest)
}
}
@@ -71,7 +52,7 @@ func (c *rateLimitedHTTPClient) waitUntil(retryAfterHeader string) {
c.mu.Lock()
var rateLimitWait time.Duration
if retryAfterHeader != "" {
- rateLimitWait = parseRetryAfter(retryAfterHeader)
+ rateLimitWait = httpjson.ParseRetryAfter(retryAfterHeader)
resumeAt := time.Now().Add(rateLimitWait)
if resumeAt.After(c.resumeAt) {
c.resumeAt = resumeAt
@@ -86,29 +67,3 @@ func (c *rateLimitedHTTPClient) waitUntil(retryAfterHeader string) {
time.Sleep(wait)
}
}
-
-func parseRetryAfter(header string) time.Duration {
- if header == "" {
- return defaultRetryAfter
- }
- if seconds, err := strconv.Atoi(header); err == nil {
- if seconds <= 0 {
- return defaultRetryAfter
- }
- return time.Duration(seconds) * time.Second
- }
- if retryTime, err := http.ParseTime(header); err == nil {
- wait := time.Until(retryTime)
- if wait <= 0 {
- return defaultRetryAfter
- }
- return wait
- }
- return defaultRetryAfter
-}
-
-func wikidataAPIHeaders() map[string]string {
- return map[string]string{
- "User-Agent": "Mozilla/5.0 (compatible; coreander/1.0; +https://github.com/svera/coreander)",
- }
-}
diff --git a/internal/datasource/wikidata/httpclient_test.go b/internal/datasource/wikidata/httpclient_test.go
index 61fcfa30..116e07fb 100644
--- a/internal/datasource/wikidata/httpclient_test.go
+++ b/internal/datasource/wikidata/httpclient_test.go
@@ -8,28 +8,6 @@ import (
"time"
)
-func TestParseRetryAfter(t *testing.T) {
- t.Run("seconds", func(t *testing.T) {
- if got := parseRetryAfter("30"); got != 30*time.Second {
- t.Fatalf("expected 30s, got %s", got)
- }
- })
-
- t.Run("http date", func(t *testing.T) {
- retryTime := time.Now().Add(45 * time.Second).UTC()
- got := parseRetryAfter(retryTime.Format(http.TimeFormat))
- if got < 44*time.Second || got > 46*time.Second {
- t.Fatalf("expected about 45s, got %s", got)
- }
- })
-
- t.Run("missing header uses default", func(t *testing.T) {
- if got := parseRetryAfter(""); got != defaultRetryAfter {
- t.Fatalf("expected default %s, got %s", defaultRetryAfter, got)
- }
- })
-}
-
func TestRateLimitedHTTPClientWaitsForRetryAfter(t *testing.T) {
var requests atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
diff --git a/internal/index/bleve.go b/internal/index/bleve.go
index da4cc666..2777e690 100644
--- a/internal/index/bleve.go
+++ b/internal/index/bleve.go
@@ -30,7 +30,7 @@ import (
// DocumentVersion identifies the mapping used for indexing documents. Any changes in the mapping requires an increase
// of version, to signal that a new index needs to be created.
-const DocumentVersion = "v13"
+const DocumentVersion = "v14"
// AuthorVersion identifies the mapping used for indexing authors. Any changes in the mapping requires an increase
// of version, to signal that a new index needs to be created.
@@ -182,6 +182,12 @@ type Config struct {
// to already be resolved (e.g. via ResolveMetadataWorkers); <= 0 falls
// back to 1.
TextRankEnrichWorkers int
+ // GoodReadsSource, if set, is used to fill each document's GoodReads URLs
+ // in the background (see EnrichGoodReads). Nil disables it.
+ GoodReadsSource GoodReadsSource
+ // GoodReadsEnrichInterval is the minimum time between two GoodReads
+ // lookups; <= 0 falls back to DefaultGoodReadsEnrichInterval.
+ GoodReadsEnrichInterval time.Duration
}
type BleveIndexer struct {
@@ -215,6 +221,13 @@ type BleveIndexer struct {
// textRankEnrichJobs feeds documents queued by indexFile to a fixed-size
// pool of long-lived worker goroutines; see runTextRankEnrichWorker.
textRankEnrichJobs chan Document
+ // goodReadsEnrichJobs feeds IDs of documents queued by indexFile to
+ // runGoodReadsEnrichWorker; only used when goodReadsSource is set.
+ goodReadsEnrichJobs chan string
+ goodReadsSource GoodReadsSource
+ goodReadsInterval time.Duration
+ goodReadsThrottleMu sync.Mutex
+ goodReadsLastRequest time.Time
// fileLocks holds a *sync.Mutex per document ID, serializing concurrent
// indexFile calls for the same file (upload vs. file watcher reacting to
// the same write).
@@ -319,12 +332,22 @@ func NewBleve(documentsIndex bleve.Index, authorsIndex bleve.Index, fs afero.Fs,
minCommonTextRankAbsoluteCount: minCommonTextRankAbsoluteCount,
pruneChangeTriggerRatio: pruneChangeTriggerRatio,
textRankEnrichJobs: make(chan Document, textRankEnrichWorkers),
+ goodReadsSource: cfg.GoodReadsSource,
+ goodReadsInterval: cfg.GoodReadsEnrichInterval,
}
for range textRankEnrichWorkers {
go b.runTextRankEnrichWorker()
}
+ if b.goodReadsInterval <= 0 {
+ b.goodReadsInterval = DefaultGoodReadsEnrichInterval
+ }
+ if b.goodReadsSource != nil {
+ b.goodReadsEnrichJobs = make(chan string, 1)
+ go b.runGoodReadsEnrichWorker()
+ }
+
return b
}
@@ -376,6 +399,14 @@ func CreateDocumentsMapping() mapping.IndexMapping {
keywordFieldMapping := bleve.NewKeywordFieldMapping()
+ // Stored only, so the field can be read back but never matches a search.
+ // IncludeInAll=false is redundant with Index=false today, but keeps the
+ // field out of the composite _all field explicitly.
+ storedOnlyFieldMapping := bleve.NewKeywordFieldMapping()
+ storedOnlyFieldMapping.Index = false
+ storedOnlyFieldMapping.IncludeInAll = false
+ storedOnlyFieldMapping.DocValues = false
+
simpleTextFieldMapping := bleve.NewTextFieldMapping()
simpleTextFieldMapping.Analyzer = defaultAnalyzer
@@ -434,6 +465,9 @@ func CreateDocumentsMapping() mapping.IndexMapping {
indexMapping.TypeMapping[lang].AddFieldMappingsAt("Illustrations", numericFieldMapping)
indexMapping.TypeMapping[lang].AddFieldMappingsAt("AddedOn", dateTimeFieldMapping)
indexMapping.TypeMapping[lang].AddFieldMappingsAt("TextRankEnriched", booleanFieldMapping)
+ indexMapping.TypeMapping[lang].AddFieldMappingsAt("GoodReadsBookURL", storedOnlyFieldMapping)
+ indexMapping.TypeMapping[lang].AddFieldMappingsAt("GoodReadsAuthorURL", storedOnlyFieldMapping)
+ indexMapping.TypeMapping[lang].AddFieldMappingsAt("GoodReadsEnriched", booleanFieldMapping)
}
indexMapping.DefaultMapping.DefaultAnalyzer = defaultAnalyzer
@@ -458,6 +492,9 @@ func CreateDocumentsMapping() mapping.IndexMapping {
indexMapping.DefaultMapping.AddFieldMappingsAt("Illustrations", numericFieldMapping)
indexMapping.DefaultMapping.AddFieldMappingsAt("AddedOn", dateTimeFieldMapping)
indexMapping.DefaultMapping.AddFieldMappingsAt("TextRankEnriched", booleanFieldMapping)
+ indexMapping.DefaultMapping.AddFieldMappingsAt("GoodReadsBookURL", storedOnlyFieldMapping)
+ indexMapping.DefaultMapping.AddFieldMappingsAt("GoodReadsAuthorURL", storedOnlyFieldMapping)
+ indexMapping.DefaultMapping.AddFieldMappingsAt("GoodReadsEnriched", booleanFieldMapping)
return indexMapping
}
diff --git a/internal/index/bleve_document_read.go b/internal/index/bleve_document_read.go
index 48ee2b13..9994ab51 100644
--- a/internal/index/bleve_document_read.go
+++ b/internal/index/bleve_document_read.go
@@ -1023,6 +1023,10 @@ func hydrateDocument(match *search.DocumentMatch) Document {
textRankEnriched = match.Fields["TextRankEnriched"].(bool)
}
+ goodReadsBookURL, _ := match.Fields["GoodReadsBookURL"].(string)
+ goodReadsAuthorURL, _ := match.Fields["GoodReadsAuthorURL"].(string)
+ goodReadsEnriched, _ := match.Fields["GoodReadsEnriched"].(bool)
+
doc := Document{
ID: match.ID,
Metadata: metadata.Metadata{
@@ -1040,15 +1044,18 @@ func hydrateDocument(match *search.DocumentMatch) Document {
Illustrations: illustrations,
Format: match.Fields["Format"].(string),
},
- Slug: match.Fields["Slug"].(string),
- AuthorsSlugs: slicer(match.Fields["AuthorsSlugs"]),
- IllustratorsSlugs: illustratorsSlugs,
- SeriesSlug: match.Fields["SeriesSlug"].(string),
- SubjectsSlugs: slicer(match.Fields["SubjectsSlugs"]),
- AddedOn: addedOn,
- TextRankPhrases: textRankPhrases,
- TextRankWords: textRankWords,
- TextRankEnriched: textRankEnriched,
+ Slug: match.Fields["Slug"].(string),
+ AuthorsSlugs: slicer(match.Fields["AuthorsSlugs"]),
+ IllustratorsSlugs: illustratorsSlugs,
+ SeriesSlug: match.Fields["SeriesSlug"].(string),
+ SubjectsSlugs: slicer(match.Fields["SubjectsSlugs"]),
+ AddedOn: addedOn,
+ TextRankPhrases: textRankPhrases,
+ TextRankWords: textRankWords,
+ TextRankEnriched: textRankEnriched,
+ GoodReadsBookURL: goodReadsBookURL,
+ GoodReadsAuthorURL: goodReadsAuthorURL,
+ GoodReadsEnriched: goodReadsEnriched,
}
return doc
diff --git a/internal/index/bleve_document_write.go b/internal/index/bleve_document_write.go
index 110a892a..fa594df4 100644
--- a/internal/index/bleve_document_write.go
+++ b/internal/index/bleve_document_write.go
@@ -145,7 +145,10 @@ func (b *BleveIndexer) indexFileLocked(file string) (string, error) {
}
document.Metadata = job.meta
document.TextRankEnriched = !b.supportsTextRank(file)
+ documents := []Document{document}
b.documentsMu.Lock()
+ b.preserveGoodReadsLocked(documents)
+ document = documents[0]
err = b.documentsIdx.Index(id, document)
b.documentsMu.Unlock()
if err != nil {
@@ -181,6 +184,7 @@ func (b *BleveIndexer) indexFileLocked(file string) (string, error) {
if !document.TextRankEnriched {
b.scheduleTextRankEnrichment(document)
}
+ b.scheduleGoodReadsEnrichment(document.ID)
b.maybePruneForLibraryChange()
diff --git a/internal/index/bleve_goodreads_write.go b/internal/index/bleve_goodreads_write.go
new file mode 100644
index 00000000..1cf55be5
--- /dev/null
+++ b/internal/index/bleve_goodreads_write.go
@@ -0,0 +1,323 @@
+package index
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "log"
+ "regexp"
+ "strings"
+ "time"
+ "unicode"
+
+ "github.com/blevesearch/bleve/v2"
+ "github.com/blevesearch/bleve/v2/search/query"
+ "github.com/svera/coreander/v5/internal/datasource/biblioreads"
+ "golang.org/x/text/unicode/norm"
+)
+
+const (
+ // DefaultGoodReadsEnrichInterval is the minimum time between two GoodReads lookups.
+ DefaultGoodReadsEnrichInterval = time.Second
+ goodReadsRequestTimeout = 2 * time.Minute
+)
+
+// GoodReadsSource searches books on Goodreads; implemented by *biblioreads.Client.
+type GoodReadsSource interface {
+ SearchBooks(ctx context.Context, query string) (biblioreads.SearchBooksResponse, error)
+}
+
+var goodReadsFields = []string{"GoodReadsBookURL", "GoodReadsAuthorURL", "GoodReadsEnriched"}
+
+// Strip named series annotations and omnibus ranges, not standalone volume numbers.
+var goodReadsSeriesSuffix = regexp.MustCompile(`\s*\((?:[^()]+,\s*#[0-9]+(?:\.[0-9]+)?|#[0-9]+\s*[-–]\s*#?[0-9]+)\)\s*$`)
+
+func normalizeGoodReadsName(value string) string {
+ var result strings.Builder
+ for _, r := range norm.NFD.String(strings.ToLower(value)) {
+ switch {
+ case unicode.Is(unicode.Mn, r):
+ continue
+ case unicode.IsLetter(r), unicode.IsNumber(r):
+ result.WriteRune(r)
+ default:
+ result.WriteByte(' ')
+ }
+ }
+ return strings.Join(strings.Fields(result.String()), " ")
+}
+
+func goodReadsTitle(value string) string {
+ return normalizeGoodReadsName(goodReadsSeriesSuffix.ReplaceAllString(value, ""))
+}
+
+func matchingGoodReadsBook(document Document, results []biblioreads.SearchResult) (biblioreads.SearchResult, bool) {
+ title := goodReadsTitle(document.Title)
+ if title == "" {
+ return biblioreads.SearchResult{}, false
+ }
+ var best biblioreads.SearchResult
+ bestScore := 0
+ for _, result := range results {
+ if result.BookURL == "" || goodReadsTitle(result.Title) != title {
+ continue
+ }
+ author := strings.ReplaceAll(normalizeGoodReadsName(result.Author), " ", "")
+ if author == "" {
+ continue
+ }
+ for _, name := range document.Authors {
+ if strings.ReplaceAll(normalizeGoodReadsName(name), " ", "") != author {
+ continue
+ }
+ score := 1
+ if normalizeGoodReadsName(result.Title) == normalizeGoodReadsName(document.Title) {
+ score = 2
+ }
+ if score > bestScore {
+ best, bestScore = result, score
+ }
+ break
+ }
+ }
+ return best, bestScore > 0
+}
+
+// goodReadsQuery returns the Goodreads search string for document: its title
+// followed by its first author, if any.
+func goodReadsQuery(document Document) string {
+ q := strings.TrimSpace(document.Title)
+ if len(document.Authors) > 0 {
+ q = strings.TrimSpace(q + " " + document.Authors[0])
+ }
+ return q
+}
+
+func documentsNeedingGoodReadsQuery() query.Query {
+ notEnriched := bleve.NewBoolFieldQuery(false)
+ notEnriched.SetField("GoodReadsEnriched")
+ return notEnriched
+}
+
+func (b *BleveIndexer) documentsNeedingGoodReads(size int) ([]Document, uint64, error) {
+ searchReq := bleve.NewSearchRequestOptions(documentsNeedingGoodReadsQuery(), size, 0, false)
+ searchReq.Fields = []string{"*"}
+ b.documentsMu.RLock()
+ searchResult, err := b.documentsIdx.Search(searchReq)
+ b.documentsMu.RUnlock()
+ if err != nil {
+ return nil, 0, err
+ }
+ documents := make([]Document, 0, len(searchResult.Hits))
+ for _, hit := range searchResult.Hits {
+ documents = append(documents, hydrateDocument(hit))
+ }
+ return documents, searchResult.Total, nil
+}
+
+// EnrichGoodReads looks up on Goodreads every document not processed yet (see
+// Document.GoodReadsEnriched), storing the book and author URLs of a matching
+// search result. Lookups are throttled (see Config.GoodReadsEnrichInterval).
+// It stops at the first error that means Goodreads can't be reached right
+// now, leaving the remaining documents for the next run.
+func (b *BleveIndexer) EnrichGoodReads(batchSize int) error {
+ if b.goodReadsSource == nil {
+ return nil
+ }
+
+ _, total, err := b.documentsNeedingGoodReads(0)
+ if err != nil || total == 0 {
+ return err
+ }
+ log.Printf("Enriching %d documents with GoodReads data", total)
+
+ // Bounds the loop if a document's write keeps failing, which would leave
+ // it in documentsNeedingGoodReads' results forever.
+ maxIterations := int(total) + 10
+ for iterations := 0; ; iterations++ {
+ if iterations >= maxIterations {
+ return fmt.Errorf("GoodReads enrichment did not finish after %d iterations, some documents may be stuck unenriched", iterations)
+ }
+ // Enriched documents drop out of the query, so it always starts from 0.
+ chunk, _, err := b.documentsNeedingGoodReads(batchSize)
+ if err != nil {
+ return err
+ }
+ if len(chunk) == 0 {
+ break
+ }
+ for _, document := range chunk {
+ if _, err := b.enrichGoodReads(context.Background(), document, true); err != nil {
+ return err
+ }
+ }
+ }
+
+ log.Printf("GoodReads enrichment finished")
+ return nil
+}
+
+// GoodReadsBookURL returns the Goodreads book path of document (see
+// Document.GoodReadsBookURL). If the document hasn't been looked up yet, it
+// searches it right away (without waiting for the background enrichment's
+// throttle) and persists the result, so the background enrichment skips it.
+// Returns an empty string if there's no match or GoodReads is disabled.
+func (b *BleveIndexer) GoodReadsBookURL(ctx context.Context, document Document) (string, error) {
+ if document.GoodReadsBookURL != "" || document.GoodReadsEnriched || b.goodReadsSource == nil {
+ return document.GoodReadsBookURL, nil
+ }
+ return b.enrichGoodReads(ctx, document, false)
+}
+
+// enrichGoodReads searches document on Goodreads, unless it already has a
+// book URL, and persists the first result's URLs, returning the book one.
+// Only errors meaning Goodreads is unreachable are returned; in that case
+// the document is left unenriched to be retried later.
+func (b *BleveIndexer) enrichGoodReads(ctx context.Context, document Document, throttle bool) (string, error) {
+ q := goodReadsQuery(document)
+ bookURL, authorURL := document.GoodReadsBookURL, document.GoodReadsAuthorURL
+ if bookURL == "" && document.Title != "" {
+ ctx, cancel := context.WithTimeout(ctx, goodReadsRequestTimeout)
+ defer cancel()
+ queries := []string{q}
+ if title := strings.TrimSpace(document.Title); title != q {
+ queries = append(queries, title)
+ }
+ for _, query := range queries {
+ if throttle {
+ b.waitGoodReadsTurn()
+ }
+ res, err := b.goodReadsSource.SearchBooks(ctx, query)
+ switch {
+ case errors.Is(err, biblioreads.ErrUnavailable), errors.Is(err, context.DeadlineExceeded), errors.Is(err, context.Canceled):
+ return "", fmt.Errorf("error searching %q on GoodReads: %w", query, err)
+ case err != nil:
+ log.Printf("Error searching %q on GoodReads: %s\n", query, err)
+ default:
+ if match, ok := matchingGoodReadsBook(document, res.Result); ok {
+ bookURL, authorURL = match.BookURL, match.AuthorURL
+ }
+ }
+ if bookURL != "" {
+ break
+ }
+ }
+ }
+
+ // Re-read the document under the write lock, so concurrent writes of other
+ // fields (e.g. TextRank enrichment) aren't overwritten with stale values.
+ b.documentsMu.Lock()
+ defer b.documentsMu.Unlock()
+ current, found, err := b.documentByIndexIDLocked(document.ID)
+ if err != nil {
+ log.Printf("Error reading document %s for GoodReads enrichment: %s\n", document.ID, err)
+ return bookURL, nil
+ }
+ // Removed, or reindexed with a different title/author in the meantime
+ // (which schedules its own lookup).
+ if !found || goodReadsQuery(current) != q {
+ return bookURL, nil
+ }
+ // Looked up concurrently (e.g. by the background enrichment and a detail
+ // page request at the same time): keep the stored result.
+ if current.GoodReadsEnriched {
+ return current.GoodReadsBookURL, nil
+ }
+ if current.GoodReadsBookURL != "" {
+ bookURL, authorURL = current.GoodReadsBookURL, current.GoodReadsAuthorURL
+ }
+ current.GoodReadsBookURL = bookURL
+ current.GoodReadsAuthorURL = authorURL
+ current.GoodReadsEnriched = true
+ if err := b.documentsIdx.Index(current.ID, current); err != nil {
+ log.Printf("Error indexing GoodReads-enriched document %s: %s\n", current.ID, err)
+ }
+ return bookURL, nil
+}
+
+// waitGoodReadsTurn blocks until at least goodReadsInterval has passed since
+// the previous GoodReads lookup, shared by EnrichGoodReads and the per-file
+// worker.
+func (b *BleveIndexer) waitGoodReadsTurn() {
+ b.goodReadsThrottleMu.Lock()
+ defer b.goodReadsThrottleMu.Unlock()
+ if wait := time.Until(b.goodReadsLastRequest.Add(b.goodReadsInterval)); wait > 0 {
+ time.Sleep(wait)
+ }
+ b.goodReadsLastRequest = time.Now()
+}
+
+// runGoodReadsEnrichWorker looks up documents queued by indexFile for the
+// indexer's lifetime.
+func (b *BleveIndexer) runGoodReadsEnrichWorker() {
+ for id := range b.goodReadsEnrichJobs {
+ document, err := b.documentByIndexID(id)
+ if err != nil {
+ log.Printf("Error reading document %s for GoodReads enrichment: %s\n", id, err)
+ continue
+ }
+ if document.ID == "" || document.GoodReadsEnriched {
+ continue
+ }
+ if _, err := b.enrichGoodReads(context.Background(), document, true); err != nil {
+ log.Println(err)
+ }
+ }
+}
+
+// scheduleGoodReadsEnrichment queues the document with the given ID for
+// lookup from a throwaway goroutine, so indexFile never blocks on it.
+func (b *BleveIndexer) scheduleGoodReadsEnrichment(id string) {
+ if b.goodReadsSource == nil {
+ return
+ }
+ go func() {
+ b.goodReadsEnrichJobs <- id
+ }()
+}
+
+// documentByIndexIDLocked is documentByIndexID for callers already holding documentsMu.
+func (b *BleveIndexer) documentByIndexIDLocked(id string) (Document, bool, error) {
+ searchReq := bleve.NewSearchRequest(bleve.NewDocIDQuery([]string{id}))
+ searchReq.Fields = []string{"*"}
+ searchResult, err := b.documentsIdx.Search(searchReq)
+ if err != nil || len(searchResult.Hits) == 0 {
+ return Document{}, false, err
+ }
+ return hydrateDocument(searchResult.Hits[0]), true, nil
+}
+
+// preserveGoodReadsLocked copies the currently stored GoodReads fields into
+// documents, so writers that rebuilt them from an earlier read don't discard
+// a lookup persisted in the meantime. Must be called with documentsMu held
+// for writing, right before writing documents back.
+func (b *BleveIndexer) preserveGoodReadsLocked(documents []Document) {
+ if len(documents) == 0 {
+ return
+ }
+ ids := make([]string, len(documents))
+ for i, document := range documents {
+ ids[i] = document.ID
+ }
+ searchReq := bleve.NewSearchRequestOptions(bleve.NewDocIDQuery(ids), len(ids), 0, false)
+ searchReq.Fields = goodReadsFields
+ searchResult, err := b.documentsIdx.Search(searchReq)
+ if err != nil {
+ log.Printf("Error reading stored GoodReads fields: %s\n", err)
+ return
+ }
+ stored := make(map[string]Document, len(searchResult.Hits))
+ for _, hit := range searchResult.Hits {
+ bookURL, _ := hit.Fields["GoodReadsBookURL"].(string)
+ authorURL, _ := hit.Fields["GoodReadsAuthorURL"].(string)
+ enriched, _ := hit.Fields["GoodReadsEnriched"].(bool)
+ stored[hit.ID] = Document{GoodReadsBookURL: bookURL, GoodReadsAuthorURL: authorURL, GoodReadsEnriched: enriched}
+ }
+ for i := range documents {
+ if s, ok := stored[documents[i].ID]; ok {
+ documents[i].GoodReadsBookURL = s.GoodReadsBookURL
+ documents[i].GoodReadsAuthorURL = s.GoodReadsAuthorURL
+ documents[i].GoodReadsEnriched = s.GoodReadsEnriched
+ }
+ }
+}
diff --git a/internal/index/bleve_goodreads_write_test.go b/internal/index/bleve_goodreads_write_test.go
new file mode 100644
index 00000000..99c9fc74
--- /dev/null
+++ b/internal/index/bleve_goodreads_write_test.go
@@ -0,0 +1,513 @@
+package index
+
+import (
+ "context"
+ "errors"
+ "image"
+ "slices"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/spf13/afero"
+ "github.com/svera/coreander/v5/internal/datasource/biblioreads"
+ "github.com/svera/coreander/v5/internal/metadata"
+)
+
+type fakeGoodReadsSource struct {
+ mu sync.Mutex
+ queries []string
+ results []biblioreads.SearchResult
+ byQuery map[string][]biblioreads.SearchResult
+ queryErrors map[string]error
+ err error
+}
+
+func (f *fakeGoodReadsSource) SearchBooks(_ context.Context, query string) (biblioreads.SearchBooksResponse, error) {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ f.queries = append(f.queries, query)
+ if f.byQuery != nil {
+ return biblioreads.SearchBooksResponse{Result: f.byQuery[query]}, f.queryErrors[query]
+ }
+ return biblioreads.SearchBooksResponse{Result: f.results}, f.err
+}
+
+func (f *fakeGoodReadsSource) Queries() []string {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ return slices.Clone(f.queries)
+}
+
+type goodReadsTestReader struct{}
+
+func (goodReadsTestReader) Metadata(string) (metadata.Metadata, error) {
+ return metadata.Metadata{Title: "La Reina Dragon", Authors: []string{"Alice Borchardt", "Second Author"}, Format: "PDF"}, nil
+}
+
+func (goodReadsTestReader) Cover(string, int) (image.Image, error) {
+ return nil, nil
+}
+
+var goodReadsTestResults = []biblioreads.SearchResult{
+ {Title: "La Reina Dragon", Author: "Alice Borchardt", BookURL: "/book/show/12207444-la-reina-dragon", AuthorURL: "/author/show/151155.Alice_Borchardt"},
+ {BookURL: "/book/show/2-other", AuthorURL: "/author/show/2.Other"},
+}
+
+const (
+ wantGoodReadsBookURL = "/book/show/12207444-la-reina-dragon"
+ wantGoodReadsAuthorURL = "/author/show/151155.Alice_Borchardt"
+ goodReadsTestID = "book.pdf"
+)
+
+func newGoodReadsTestIndexer(t *testing.T, source GoodReadsSource) *BleveIndexer {
+ t.Helper()
+ idx, _ := newTestTextRankIndexer(t, Config{GoodReadsSource: source, GoodReadsEnrichInterval: time.Millisecond})
+ appFS := afero.NewMemMapFs()
+ if err := afero.WriteFile(appFS, "lib/book.pdf", []byte(""), 0644); err != nil {
+ t.Fatal(err)
+ }
+ idx.fs = appFS
+ idx.libraryPath = "lib"
+ idx.reader = map[string]metadata.Reader{".pdf": goodReadsTestReader{}}
+ return idx
+}
+
+func TestEnrichGoodReadsStoresMatchingResult(t *testing.T) {
+ source := &fakeGoodReadsSource{results: goodReadsTestResults}
+ idx := newGoodReadsTestIndexer(t, source)
+ if err := idx.AddLibrary(10, true, 1); err != nil {
+ t.Fatal(err)
+ }
+
+ if err := idx.EnrichGoodReads(10); err != nil {
+ t.Fatal(err)
+ }
+
+ doc, err := idx.documentByIndexID(goodReadsTestID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if doc.GoodReadsBookURL != wantGoodReadsBookURL || doc.GoodReadsAuthorURL != wantGoodReadsAuthorURL {
+ t.Errorf("unexpected URLs %q and %q", doc.GoodReadsBookURL, doc.GoodReadsAuthorURL)
+ }
+ if !doc.GoodReadsEnriched {
+ t.Error("expected document to be marked as GoodReads-enriched")
+ }
+ if want := []string{"La Reina Dragon Alice Borchardt"}; !slices.Equal(source.Queries(), want) {
+ t.Errorf("expected queries %q, got %q", want, source.Queries())
+ }
+
+ if err := idx.EnrichGoodReads(10); err != nil {
+ t.Fatal(err)
+ }
+ if len(source.Queries()) != 1 {
+ t.Errorf("expected enriched documents not to be searched again, got queries %q", source.Queries())
+ }
+}
+
+func TestEnrichGoodReadsMarksDocumentsWithoutMatch(t *testing.T) {
+ for name, source := range map[string]*fakeGoodReadsSource{
+ "no results": {},
+ "non-transient err": {err: errors.New("biblioreads: request failed with status code 504")},
+ "wrong title": {results: []biblioreads.SearchResult{{Title: "Another book", Author: "Alice Borchardt", BookURL: "/book/show/1"}}},
+ "wrong author": {results: []biblioreads.SearchResult{{Title: "La Reina Dragon", Author: "Another writer", BookURL: "/book/show/1"}}},
+ } {
+ t.Run(name, func(t *testing.T) {
+ idx := newGoodReadsTestIndexer(t, source)
+ if err := idx.AddLibrary(10, true, 1); err != nil {
+ t.Fatal(err)
+ }
+ if err := idx.EnrichGoodReads(10); err != nil {
+ t.Fatal(err)
+ }
+ doc, err := idx.documentByIndexID(goodReadsTestID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !doc.GoodReadsEnriched || doc.GoodReadsBookURL != "" || doc.GoodReadsAuthorURL != "" {
+ t.Errorf("expected document enriched with no URLs, got %+v", doc)
+ }
+ if want := []string{"La Reina Dragon Alice Borchardt", "La Reina Dragon"}; !slices.Equal(source.Queries(), want) {
+ t.Errorf("expected both searches before giving up, got %q", source.Queries())
+ }
+ })
+ }
+}
+
+func TestEnrichGoodReadsLeavesDocumentsForLaterWhenUnavailable(t *testing.T) {
+ source := &fakeGoodReadsSource{err: biblioreads.ErrUnavailable}
+ idx := newGoodReadsTestIndexer(t, source)
+ if err := idx.AddLibrary(10, true, 1); err != nil {
+ t.Fatal(err)
+ }
+
+ if err := idx.EnrichGoodReads(10); !errors.Is(err, biblioreads.ErrUnavailable) {
+ t.Fatalf("expected ErrUnavailable, got %v", err)
+ }
+ doc, err := idx.documentByIndexID(goodReadsTestID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if doc.GoodReadsEnriched {
+ t.Error("expected document not to be marked as GoodReads-enriched")
+ }
+
+ source.mu.Lock()
+ source.err = nil
+ source.results = goodReadsTestResults
+ source.mu.Unlock()
+ if err := idx.EnrichGoodReads(10); err != nil {
+ t.Fatal(err)
+ }
+ if doc, _ = idx.documentByIndexID(goodReadsTestID); doc.GoodReadsBookURL != wantGoodReadsBookURL {
+ t.Errorf("expected document to be enriched on retry, got %q", doc.GoodReadsBookURL)
+ }
+}
+
+func TestNewFileSchedulesGoodReadsEnrichment(t *testing.T) {
+ source := &fakeGoodReadsSource{results: goodReadsTestResults}
+ idx := newGoodReadsTestIndexer(t, source)
+
+ if _, err := idx.NewFile("new.pdf", []byte("")); err != nil {
+ t.Fatal(err)
+ }
+
+ deadline := time.Now().Add(2 * time.Second)
+ for {
+ doc, err := idx.documentByIndexID("new.pdf")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if doc.GoodReadsEnriched {
+ if doc.GoodReadsBookURL != wantGoodReadsBookURL {
+ t.Errorf("unexpected book URL %q", doc.GoodReadsBookURL)
+ }
+ return
+ }
+ if time.Now().After(deadline) {
+ t.Fatal("timed out waiting for background GoodReads enrichment")
+ }
+ time.Sleep(10 * time.Millisecond)
+ }
+}
+
+// TestTextRankWritesPreserveGoodReadsFields guards against TextRank
+// enrichment, which writes back documents read before ranking them,
+// discarding GoodReads URLs persisted in the meantime, and vice versa.
+func TestTextRankWritesPreserveGoodReadsFields(t *testing.T) {
+ source := &fakeGoodReadsSource{results: goodReadsTestResults}
+ idx := newGoodReadsTestIndexer(t, source)
+ if err := idx.AddLibrary(10, true, 1); err != nil {
+ t.Fatal(err)
+ }
+ stale, err := idx.documentByIndexID(goodReadsTestID)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if err := idx.EnrichGoodReads(10); err != nil {
+ t.Fatal(err)
+ }
+ stale.TextRankEnriched = false
+ stale.TextRankWords = []string{"dragon"}
+ idx.enrichTextRankAndReindex(stale)
+
+ doc, err := idx.documentByIndexID(goodReadsTestID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !doc.GoodReadsEnriched || doc.GoodReadsBookURL != wantGoodReadsBookURL {
+ t.Errorf("expected GoodReads fields to survive a TextRank write, got %+v", doc)
+ }
+ if !doc.TextRankEnriched {
+ t.Error("expected TextRank write to be persisted")
+ }
+
+ // A GoodReads write must keep TextRank fields written after the document was read.
+ stale, _ = idx.documentByIndexID(goodReadsTestID)
+ stale.GoodReadsEnriched = false
+ doc.TextRankWords = []string{"guinevere"}
+ idx.documentsMu.Lock()
+ if err := idx.documentsIdx.Index(doc.ID, doc); err != nil {
+ t.Fatal(err)
+ }
+ idx.documentsMu.Unlock()
+ if _, err := idx.enrichGoodReads(context.Background(), stale, true); err != nil {
+ t.Fatal(err)
+ }
+ if doc, _ = idx.documentByIndexID(goodReadsTestID); !slices.Equal(doc.TextRankWords, []string{"guinevere"}) {
+ t.Errorf("expected TextRank words to survive a GoodReads write, got %q", doc.TextRankWords)
+ }
+}
+
+func TestEnrichGoodReadsWithoutSourceIsNoop(t *testing.T) {
+ idx := newGoodReadsTestIndexer(t, nil)
+ if err := idx.AddLibrary(10, true, 1); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := idx.NewFile("new.pdf", []byte("")); err != nil {
+ t.Fatal(err)
+ }
+ if err := idx.EnrichGoodReads(10); err != nil {
+ t.Fatal(err)
+ }
+ doc, err := idx.documentByIndexID(goodReadsTestID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if doc.GoodReadsEnriched {
+ t.Error("expected document not to be GoodReads-enriched when the source is disabled")
+ }
+}
+
+func TestEnrichGoodReadsSkipsSearchWhenDocumentHasLink(t *testing.T) {
+ source := &fakeGoodReadsSource{results: goodReadsTestResults}
+ idx := newGoodReadsTestIndexer(t, source)
+ if err := idx.AddLibrary(10, true, 1); err != nil {
+ t.Fatal(err)
+ }
+ doc, err := idx.documentByIndexID(goodReadsTestID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ doc.GoodReadsBookURL = "/book/show/1-existing"
+ doc.GoodReadsAuthorURL = "/author/show/1.Existing"
+ if err := idx.documentsIdx.Index(doc.ID, doc); err != nil {
+ t.Fatal(err)
+ }
+
+ if err := idx.EnrichGoodReads(10); err != nil {
+ t.Fatal(err)
+ }
+
+ if len(source.Queries()) != 0 {
+ t.Errorf("expected no search for a document with a link, got %q", source.Queries())
+ }
+ doc, err = idx.documentByIndexID(goodReadsTestID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !doc.GoodReadsEnriched || doc.GoodReadsBookURL != "/book/show/1-existing" || doc.GoodReadsAuthorURL != "/author/show/1.Existing" {
+ t.Errorf("expected existing link kept and document marked enriched, got %+v", doc)
+ }
+}
+
+func TestGoodReadsBookURL(t *testing.T) {
+ source := &fakeGoodReadsSource{results: goodReadsTestResults}
+ idx := newGoodReadsTestIndexer(t, source)
+ if err := idx.AddLibrary(10, true, 1); err != nil {
+ t.Fatal(err)
+ }
+ doc, err := idx.documentByIndexID(goodReadsTestID)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ got, err := idx.GoodReadsBookURL(context.Background(), doc)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got != wantGoodReadsBookURL {
+ t.Errorf("expected %q, got %q", wantGoodReadsBookURL, got)
+ }
+ stored, err := idx.documentByIndexID(goodReadsTestID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !stored.GoodReadsEnriched || stored.GoodReadsBookURL != wantGoodReadsBookURL {
+ t.Errorf("expected the link to be persisted, got %+v", stored)
+ }
+
+ if got, _ := idx.GoodReadsBookURL(context.Background(), stored); got != wantGoodReadsBookURL {
+ t.Errorf("expected stored link %q, got %q", wantGoodReadsBookURL, got)
+ }
+ if err := idx.EnrichGoodReads(10); err != nil {
+ t.Fatal(err)
+ }
+ if len(source.Queries()) != 1 {
+ t.Errorf("expected a single search, got %q", source.Queries())
+ }
+}
+
+func TestGoodReadsBookURLReturnsErrorWhenUnavailable(t *testing.T) {
+ source := &fakeGoodReadsSource{err: biblioreads.ErrUnavailable}
+ idx := newGoodReadsTestIndexer(t, source)
+ if err := idx.AddLibrary(10, true, 1); err != nil {
+ t.Fatal(err)
+ }
+ doc, _ := idx.documentByIndexID(goodReadsTestID)
+ if _, err := idx.GoodReadsBookURL(context.Background(), doc); !errors.Is(err, biblioreads.ErrUnavailable) {
+ t.Fatalf("expected ErrUnavailable, got %v", err)
+ }
+ if doc, _ = idx.documentByIndexID(goodReadsTestID); doc.GoodReadsEnriched {
+ t.Error("expected document to stay unenriched for a later retry")
+ }
+}
+
+func TestMatchingGoodReadsBook(t *testing.T) {
+ correct := biblioreads.SearchResult{
+ Title: "El señor de los anillos", Author: "J.R.R. Tolkien",
+ BookURL: "/book/show/37910810", AuthorURL: "/author/show/656983.J_R_R_Tolkien",
+ }
+ parody := biblioreads.SearchResult{
+ Title: "El sopor de los anillos", Author: "The Harvard Lampoon",
+ BookURL: "/book/show/7297403-el-sopor-de-los-anillos",
+ }
+ tests := []struct {
+ name string
+ title string
+ authors []string
+ results []biblioreads.SearchResult
+ want string
+ }{
+ {name: "correct result after parody", results: []biblioreads.SearchResult{parody, correct}, want: correct.BookURL},
+ {name: "parody rejected", results: []biblioreads.SearchResult{parody}},
+ {name: "similar title with correct author rejected", results: []biblioreads.SearchResult{{Title: parody.Title, Author: correct.Author, BookURL: parody.BookURL}}},
+ {name: "wrong author rejected", results: []biblioreads.SearchResult{{Title: correct.Title, Author: parody.Author, BookURL: parody.BookURL}}},
+ {name: "normalization", title: " EL SEN\u0303OR DE LOS ANILLOS! ", results: []biblioreads.SearchResult{correct}, want: correct.BookURL},
+ {name: "series suffix", results: []biblioreads.SearchResult{{Title: correct.Title + " (El señor de los anillos, #1)", Author: correct.Author, BookURL: correct.BookURL}}, want: correct.BookURL},
+ {name: "document series suffix", title: correct.Title + " (Middle Earth, #1)", results: []biblioreads.SearchResult{correct}, want: correct.BookURL},
+ {name: "omnibus range", results: []biblioreads.SearchResult{{Title: correct.Title + " (#1-#3)", Author: correct.Author, BookURL: correct.BookURL}}, want: correct.BookURL},
+ {name: "omnibus range with dash", results: []biblioreads.SearchResult{{Title: correct.Title + " (#1–3)", Author: correct.Author, BookURL: correct.BookURL}}, want: correct.BookURL},
+ {name: "document omnibus range", title: correct.Title + " (#1-#3)", results: []biblioreads.SearchResult{correct}, want: correct.BookURL},
+ {name: "standalone volume retained", results: []biblioreads.SearchResult{{Title: correct.Title + " (#1)", Author: correct.Author, BookURL: correct.BookURL}}},
+ {name: "exact preferred", results: []biblioreads.SearchResult{{Title: correct.Title + " (Middle Earth, #1)", Author: correct.Author, BookURL: "/book/show/2"}, correct}, want: correct.BookURL},
+ {name: "subtitle retained", results: []biblioreads.SearchResult{{Title: correct.Title + ": A parody", Author: correct.Author, BookURL: correct.BookURL}}},
+ {name: "volume retained", results: []biblioreads.SearchResult{{Title: correct.Title + " 2", Author: correct.Author, BookURL: correct.BookURL}}},
+ {name: "arbitrary parenthetical retained", results: []biblioreads.SearchResult{{Title: correct.Title + " (A parody)", Author: correct.Author, BookURL: correct.BookURL}}},
+ {name: "missing result author", results: []biblioreads.SearchResult{{Title: correct.Title, BookURL: correct.BookURL}}},
+ {name: "missing result title", results: []biblioreads.SearchResult{{Author: correct.Author, BookURL: correct.BookURL}}},
+ {name: "missing URL", results: []biblioreads.SearchResult{{Title: correct.Title, Author: correct.Author}}},
+ {name: "any document author", authors: []string{"Another Writer", "J. R. R. Tolkien"}, results: []biblioreads.SearchResult{correct}, want: correct.BookURL},
+ {name: "no author", authors: []string{}, results: []biblioreads.SearchResult{correct}},
+ {name: "no results"},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ title, authors := tt.title, tt.authors
+ if title == "" {
+ title = correct.Title
+ }
+ if authors == nil {
+ authors = []string{"J. R. R. Tolkien"}
+ }
+ match, ok := matchingGoodReadsBook(Document{Metadata: metadata.Metadata{Title: title, Authors: authors}}, tt.results)
+ if match.BookURL != tt.want || ok != (tt.want != "") {
+ t.Fatalf("got %q, %v; want %q", match.BookURL, ok, tt.want)
+ }
+ })
+ }
+}
+
+func TestGoodReadsTitleFallback(t *testing.T) {
+ const title = "El señor de los anillos"
+ correct := biblioreads.SearchResult{Title: title, Author: "J.R.R. Tolkien", BookURL: "/book/show/37910810"}
+ parody := biblioreads.SearchResult{Title: "El sopor de los anillos", Author: "The Harvard Lampoon", BookURL: "/book/show/7297403"}
+ for _, fallbackUnavailable := range []bool{false, true} {
+ name := "matched"
+ if fallbackUnavailable {
+ name = "unavailable"
+ }
+ t.Run(name, func(t *testing.T) {
+ source := &fakeGoodReadsSource{byQuery: map[string][]biblioreads.SearchResult{
+ title + " J. R. R. Tolkien": {parody},
+ title: {parody, correct},
+ }}
+ idx := newGoodReadsTestIndexer(t, source)
+ doc := Document{ID: goodReadsTestID, Metadata: metadata.Metadata{Title: title, Authors: []string{"J. R. R. Tolkien"}}}
+ if err := idx.documentsIdx.Index(doc.ID, doc); err != nil {
+ t.Fatal(err)
+ }
+ if fallbackUnavailable {
+ source.queryErrors = map[string]error{title: biblioreads.ErrUnavailable}
+ }
+ got, err := idx.GoodReadsBookURL(context.Background(), doc)
+ if fallbackUnavailable {
+ if !errors.Is(err, biblioreads.ErrUnavailable) || got != "" {
+ t.Fatalf("expected unavailable error, got %q, %v", got, err)
+ }
+ stored, readErr := idx.documentByIndexID(doc.ID)
+ if readErr != nil || stored.GoodReadsEnriched {
+ t.Fatalf("expected document left for retry, got %+v, %v", stored, readErr)
+ }
+ source.queryErrors = nil
+ got, err = idx.GoodReadsBookURL(context.Background(), stored)
+ }
+ if err != nil || got != correct.BookURL {
+ t.Fatalf("got %q, %v; want %q", got, err, correct.BookURL)
+ }
+ want := []string{title + " J. R. R. Tolkien", title}
+ if fallbackUnavailable {
+ want = append(want, want...)
+ }
+ if !slices.Equal(source.Queries(), want) {
+ t.Fatalf("queries = %q, want %q", source.Queries(), want)
+ }
+ })
+ }
+}
+
+func TestGoodReadsMatchesTolkienOmnibusSearchResults(t *testing.T) {
+ const title = "El señor de los anillos"
+ const wantURL = "/book/show/37910810-el-se-or-de-los-anillos"
+ // Titles and ordering from BiblioReads' actual title-only search response.
+ results := []biblioreads.SearchResult{
+ {Title: "La comunidad del anillo (El señor de los anillos, #1)", Author: "J.R.R. Tolkien", BookURL: "/book/show/222947.La_comunidad_del_anillo"},
+ {Title: "Las dos torres (El señor de los anillos, #2)", Author: "J.R.R. Tolkien", BookURL: "/book/show/15280.Las_dos_torres"},
+ {Title: "El retorno del rey (El señor de los anillos, #3)", Author: "J.R.R. Tolkien", BookURL: "/book/show/63358.El_retorno_del_rey"},
+ {Title: "El señor de los anillos (#1-#3)", Author: "J.R.R. Tolkien", BookURL: wantURL, AuthorURL: "/author/show/656983.J_R_R_Tolkien"},
+ {Title: "Apéndices (El Señor de los Anillos, #4)", Author: "J.R.R. Tolkien", BookURL: "/book/show/54694.Ap_ndices"},
+ }
+ source := &fakeGoodReadsSource{byQuery: map[string][]biblioreads.SearchResult{title: results}}
+ idx := newGoodReadsTestIndexer(t, source)
+ doc := Document{ID: goodReadsTestID, Metadata: metadata.Metadata{Title: title, Authors: []string{"J. R. R. Tolkien"}}}
+ if err := idx.documentsIdx.Index(doc.ID, doc); err != nil {
+ t.Fatal(err)
+ }
+ got, err := idx.GoodReadsBookURL(context.Background(), doc)
+ if err != nil || got != wantURL {
+ t.Fatalf("got %q, %v; want %q", got, err, wantURL)
+ }
+ stored, err := idx.documentByIndexID(doc.ID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !stored.GoodReadsEnriched || stored.GoodReadsBookURL != wantURL || stored.GoodReadsAuthorURL != results[3].AuthorURL {
+ t.Fatalf("expected persisted omnibus match, got %+v", stored)
+ }
+ if want := []string{title + " J. R. R. Tolkien", title}; !slices.Equal(source.Queries(), want) {
+ t.Fatalf("queries = %q, want %q", source.Queries(), want)
+ }
+}
+
+func TestForceIndexingRefreshesGoodReadsMatch(t *testing.T) {
+ source := &fakeGoodReadsSource{results: goodReadsTestResults}
+ idx := newGoodReadsTestIndexer(t, source)
+ if err := idx.AddLibrary(10, true, 1); err != nil {
+ t.Fatal(err)
+ }
+ doc, err := idx.documentByIndexID(goodReadsTestID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ doc.GoodReadsBookURL = "/book/show/1-wrong"
+ doc.GoodReadsAuthorURL = "/author/show/1.Wrong"
+ doc.GoodReadsEnriched = true
+ if err := idx.documentsIdx.Index(doc.ID, doc); err != nil {
+ t.Fatal(err)
+ }
+ if err := idx.AddLibrary(10, true, 1); err != nil {
+ t.Fatal(err)
+ }
+ if err := idx.EnrichGoodReads(10); err != nil {
+ t.Fatal(err)
+ }
+ doc, err = idx.documentByIndexID(goodReadsTestID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if doc.GoodReadsBookURL != wantGoodReadsBookURL || doc.GoodReadsAuthorURL != wantGoodReadsAuthorURL {
+ t.Fatalf("expected refreshed links, got %q and %q", doc.GoodReadsBookURL, doc.GoodReadsAuthorURL)
+ }
+}
diff --git a/internal/index/bleve_textrank_write.go b/internal/index/bleve_textrank_write.go
index ad9335b1..f0f38104 100644
--- a/internal/index/bleve_textrank_write.go
+++ b/internal/index/bleve_textrank_write.go
@@ -85,8 +85,10 @@ func (b *BleveIndexer) scheduleTextRankEnrichment(document Document) {
// waiting on TextRank.
func (b *BleveIndexer) enrichTextRankAndReindex(document Document) {
enriched := b.rankDocument(document)
+ documents := []Document{enriched}
b.documentsMu.Lock()
- err := b.documentsIdx.Index(enriched.ID, enriched)
+ b.preserveGoodReadsLocked(documents)
+ err := b.documentsIdx.Index(enriched.ID, documents[0])
b.documentsMu.Unlock()
if err != nil {
log.Printf("Error indexing TextRank-enriched document %s: %s\n", enriched.ID, err)
@@ -263,13 +265,14 @@ func (b *BleveIndexer) EnrichTextRankKeywords(batchSize, workers int) error {
enriched := b.rankDocuments(chunk, workers)
+ b.documentsMu.Lock()
+ b.preserveGoodReadsLocked(enriched)
batch := b.documentsIdx.NewBatch()
for _, document := range enriched {
if err := batch.Index(document.ID, document); err != nil {
log.Printf("Error indexing enriched document %s: %s\n", document.ID, err)
}
}
- b.documentsMu.Lock()
err = b.documentsIdx.Batch(batch)
b.documentsMu.Unlock()
if err != nil {
@@ -529,7 +532,7 @@ func (b *BleveIndexer) rewriteCommonTextRankEntries(docCount uint64, batchSize i
return pruned, err
}
- batch := b.documentsIdx.NewBatch()
+ var documents []Document
for _, hit := range docSearchResult.Hits {
document := hydrateDocument(hit)
p, ok := toPrune[document.ID]
@@ -538,20 +541,25 @@ func (b *BleveIndexer) rewriteCommonTextRankEntries(docCount uint64, batchSize i
}
document.TextRankPhrases = p.phrases
document.TextRankWords = p.words
+ documents = append(documents, document)
+ }
+
+ b.documentsMu.Lock()
+ b.preserveGoodReadsLocked(documents)
+ batch := b.documentsIdx.NewBatch()
+ for _, document := range documents {
if err := batch.Index(document.ID, document); err != nil {
log.Printf("Error indexing document %s while pruning common TextRank entries: %s\n", document.ID, err)
continue
}
pruned++
}
-
if batch.Size() > 0 {
- b.documentsMu.Lock()
err = b.documentsIdx.Batch(batch)
- b.documentsMu.Unlock()
- if err != nil {
- return pruned, err
- }
+ }
+ b.documentsMu.Unlock()
+ if err != nil {
+ return pruned, err
}
b.recordPruningProgress(len(result.Hits))
}
diff --git a/internal/index/document.go b/internal/index/document.go
index 07dfe299..5ef44084 100644
--- a/internal/index/document.go
+++ b/internal/index/document.go
@@ -76,6 +76,17 @@ type Document struct {
// in the background. See documentsNeedingTextRank, which drives that pass
// off this field so it survives (and resumes correctly across) restarts.
TextRankEnriched bool
+ // GoodReadsBookURL and GoodReadsAuthorURL link the document to its
+ // Goodreads book and author pages. They are stored but not indexed (see
+ // CreateDocumentsMapping), so they never affect search results. They are
+ // filled in the background by the GoodReads enrichment (see
+ // EnrichGoodReads), with paths relative to https://www.goodreads.com
+ // (e.g. "/book/show/12207444-la-reina-dragon"), as returned by BiblioReads.
+ GoodReadsBookURL string
+ GoodReadsAuthorURL string
+ // GoodReadsEnriched is false until the GoodReads enrichment has looked
+ // this document up, whether or not a match was found.
+ GoodReadsEnriched bool
}
func (d Document) SupportsAnnotations() bool {
diff --git a/internal/index/goodreads_fields_test.go b/internal/index/goodreads_fields_test.go
new file mode 100644
index 00000000..ad0dc68b
--- /dev/null
+++ b/internal/index/goodreads_fields_test.go
@@ -0,0 +1,86 @@
+package index_test
+
+import (
+ "testing"
+
+ "github.com/blevesearch/bleve/v2"
+ "github.com/blevesearch/bleve/v2/search/query"
+ "github.com/svera/coreander/v5/internal/index"
+ "github.com/svera/coreander/v5/internal/metadata"
+)
+
+func TestGoodReadsURLsAreStoredButNotSearchable(t *testing.T) {
+ documentsIndexMem, err := bleve.NewMemOnly(index.CreateDocumentsMapping())
+ if err != nil {
+ t.Fatal(err)
+ }
+ authorsIndexMem, err := bleve.NewMemOnly(index.CreateAuthorsMapping())
+ if err != nil {
+ t.Fatal(err)
+ }
+ idx := index.NewBleve(documentsIndexMem, authorsIndexMem, nil, "", nil, index.Config{})
+
+ const (
+ bookURL = "/book/show/12207444-zyxwvbook"
+ authorURL = "/author/show/151155.Zyxwv_Author"
+ )
+ for _, lang := range []string{"es", ""} {
+ doc := index.Document{
+ ID: "doc-" + lang,
+ Slug: "doc-" + lang,
+ Metadata: metadata.Metadata{
+ Title: "La Reina Dragon",
+ Authors: []string{"Alice Borchardt"},
+ Language: lang,
+ Format: "EPUB",
+ },
+ AuthorsSlugs: []string{"alice-borchardt"},
+ GoodReadsBookURL: bookURL,
+ GoodReadsAuthorURL: authorURL,
+ }
+ if err := documentsIndexMem.Index(doc.ID, doc); err != nil {
+ t.Fatal(err)
+ }
+
+ got, err := idx.Document(doc.Slug)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got.GoodReadsBookURL != bookURL || got.GoodReadsAuthorURL != authorURL {
+ t.Errorf("language %q: expected stored URLs to be returned, got %q and %q", lang, got.GoodReadsBookURL, got.GoodReadsAuthorURL)
+ }
+ }
+
+ for _, keywords := range []string{"zyxwvbook", "Zyxwv_Author", "12207444", "151155"} {
+ res, err := idx.Search(index.SearchFields{Keywords: keywords}, 1, 10)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if res.TotalHits() != 0 {
+ t.Errorf("expected no results searching keywords %q, got %d", keywords, res.TotalHits())
+ }
+ }
+
+ queries := map[string]bleve.SearchRequest{
+ "book URL field term": *bleve.NewSearchRequest(fieldTermQuery("GoodReadsBookURL", bookURL)),
+ "author URL field term": *bleve.NewSearchRequest(fieldTermQuery("GoodReadsAuthorURL", authorURL)),
+ "_all match": *bleve.NewSearchRequest(bleve.NewMatchQuery("zyxwvbook")),
+ "_all term": *bleve.NewSearchRequest(bleve.NewTermQuery(bookURL)),
+ "query string field": *bleve.NewSearchRequest(bleve.NewQueryStringQuery(`GoodReadsBookURL:"` + bookURL + `"`)),
+ }
+ for name, req := range queries {
+ res, err := documentsIndexMem.Search(&req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if res.Total != 0 {
+ t.Errorf("%s: expected no hits, got %d", name, res.Total)
+ }
+ }
+}
+
+func fieldTermQuery(field, term string) *query.TermQuery {
+ q := bleve.NewTermQuery(term)
+ q.SetField(field)
+ return q
+}
diff --git a/internal/webserver/controller.go b/internal/webserver/controller.go
index 0eeb4802..bd2362e3 100644
--- a/internal/webserver/controller.go
+++ b/internal/webserver/controller.go
@@ -30,7 +30,7 @@ type Controllers struct {
Series *series.Controller
}
-func SetupControllers(cfg Config, db *gorm.DB, idx *index.BleveIndexer, sender Sender, appFs afero.Fs, dataSource author.DataSource) Controllers {
+func SetupControllers(cfg Config, db *gorm.DB, idx *index.BleveIndexer, sender Sender, appFs afero.Fs, dataSource author.DataSource, bookSource document.BookSource) Controllers {
usersRepository := &model.UserRepository{DB: db}
invitationsRepository := &model.InvitationRepository{DB: db}
highlightsRepository := &model.HighlightRepository{DB: db, Idx: idx, IllustratedMinAmount: cfg.IllustratedMinAmount}
@@ -111,7 +111,7 @@ func SetupControllers(cfg Config, db *gorm.DB, idx *index.BleveIndexer, sender S
Users: user.NewController(usersRepository, invitationsRepository, usersCfg, sender, translator),
Completed: completed.NewController(readingRepository, idx),
Highlights: highlight.NewController(highlightsRepository, readingRepository, usersRepository, sender, cfg.WordsPerMinute, idx),
- Documents: document.NewController(highlightsRepository, usersRepository, readingRepository, annotationsRepository, sender, idx, appFs, documentsCfg, translator),
+ Documents: document.NewController(highlightsRepository, usersRepository, readingRepository, annotationsRepository, sender, idx, appFs, documentsCfg, translator, bookSource),
Home: home.NewController(highlightsRepository, readingRepository, sender, idx, homeCfg),
Authors: authorsController,
Search: search.NewController(highlightsRepository, readingRepository, sender, idx, searchCfg),
diff --git a/internal/webserver/controller/document/controller.go b/internal/webserver/controller/document/controller.go
index d3a77edb..df217bf9 100644
--- a/internal/webserver/controller/document/controller.go
+++ b/internal/webserver/controller/document/controller.go
@@ -1,6 +1,7 @@
package document
import (
+ "context"
"image"
"time"
@@ -35,6 +36,7 @@ type IdxReaderWriter interface {
Languages() ([]string, error)
Subjects() (map[string][]string, error)
Author(slug, lang string) (index.Author, error)
+ GoodReadsBookURL(ctx context.Context, document index.Document) (string, error)
}
type highlightsRepository interface {
@@ -88,9 +90,11 @@ type Controller struct {
config Config
appFs afero.Fs
translator i18n.Translator
+ bookSource BookSource
}
-func NewController(hlRepository highlightsRepository, usersRepository usersRepository, readingRepository readingRepository, annotationsRepository annotationsRepository, sender Sender, idx IdxReaderWriter, appFs afero.Fs, cfg Config, translator i18n.Translator) *Controller {
+// NewController creates a document controller. bookSource may be nil, which disables Goodreads data.
+func NewController(hlRepository highlightsRepository, usersRepository usersRepository, readingRepository readingRepository, annotationsRepository annotationsRepository, sender Sender, idx IdxReaderWriter, appFs afero.Fs, cfg Config, translator i18n.Translator, bookSource BookSource) *Controller {
return &Controller{
hlRepository: hlRepository,
usersRepository: usersRepository,
@@ -101,5 +105,6 @@ func NewController(hlRepository highlightsRepository, usersRepository usersRepos
config: cfg,
appFs: appFs,
translator: translator,
+ bookSource: bookSource,
}
}
diff --git a/internal/webserver/controller/document/detail.go b/internal/webserver/controller/document/detail.go
index 29a49a6e..393f2ba7 100644
--- a/internal/webserver/controller/document/detail.go
+++ b/internal/webserver/controller/document/detail.go
@@ -71,6 +71,7 @@ func (d *Controller) Detail(c fiber.Ctx) error {
"AuthorSummaries": authorSummaries,
"IllustratorSummaries": illustratorSummaries,
"Debug": c.RequestCtx().QueryArgs().Has("debug"),
+ "GoodReadsEnabled": d.bookSource != nil,
}, "layout")
}
diff --git a/internal/webserver/controller/document/goodreads.go b/internal/webserver/controller/document/goodreads.go
new file mode 100644
index 00000000..0c528c8f
--- /dev/null
+++ b/internal/webserver/controller/document/goodreads.go
@@ -0,0 +1,69 @@
+package document
+
+import (
+ "context"
+ "log"
+ "strings"
+ "time"
+
+ "github.com/gofiber/fiber/v3"
+ "github.com/svera/coreander/v5/internal/datasource/biblioreads"
+)
+
+const (
+ goodReadsBaseURL = "https://www.goodreads.com"
+ goodReadsBookPrefix = "/book/show/"
+ goodReadsBookTimeout = 30 * time.Second
+)
+
+// BookSource retrieves book details from Goodreads; implemented by *biblioreads.Client.
+type BookSource interface {
+ Book(ctx context.Context, id string) (biblioreads.BookResponse, error)
+}
+
+// GoodReads renders the Goodreads rating shown in a document's detail page
+// metadata. It's loaded asynchronously via htmx, so slow BiblioReads
+// responses don't block the page. Renders nothing if the rating can't be
+// retrieved.
+func (d *Controller) GoodReads(c fiber.Ctx) error {
+ if d.bookSource == nil {
+ return c.SendString("")
+ }
+
+ document, err := d.idx.Document(c.Params("slug"))
+ if err != nil {
+ log.Println(err)
+ return c.SendString("")
+ }
+ if document.Slug == "" {
+ return c.SendString("")
+ }
+
+ ctx, cancel := context.WithTimeout(c.Context(), goodReadsBookTimeout)
+ defer cancel()
+ // Searches the document first if it hasn't been linked to Goodreads yet.
+ bookURL, err := d.idx.GoodReadsBookURL(ctx, document)
+ if err != nil {
+ log.Println(err)
+ return c.SendString("")
+ }
+ id, ok := strings.CutPrefix(bookURL, goodReadsBookPrefix)
+ if !ok || id == "" {
+ return c.SendString("")
+ }
+
+ book, err := d.bookSource.Book(ctx, id)
+ if err != nil {
+ log.Printf("Error retrieving GoodReads book %s: %s\n", id, err)
+ return c.SendString("")
+ }
+ if book.Rating == "" {
+ return c.SendString("")
+ }
+
+ return c.Render("partials/document-goodreads", fiber.Map{
+ "Rating": book.Rating,
+ "RatingCount": book.RatingCount,
+ "URL": goodReadsBaseURL + bookURL,
+ })
+}
diff --git a/internal/webserver/document_detail_test.go b/internal/webserver/document_detail_test.go
index d79a8947..623ad0f0 100644
--- a/internal/webserver/document_detail_test.go
+++ b/internal/webserver/document_detail_test.go
@@ -186,7 +186,7 @@ func TestRelatedDocumentHeadersShowFullTotals(t *testing.T) {
cfg := defaultTestConfig()
sender := &infrastructure.NoEmail{}
- controllers := webserver.SetupControllers(cfg, db, idx, sender, appFS, nil)
+ controllers := webserver.SetupControllers(cfg, db, idx, sender, appFS, nil, nil)
app := webserver.New(cfg, controllers, sender, idx, &model.UserRepository{DB: db})
t.Cleanup(func() { _ = app.Shutdown() })
diff --git a/internal/webserver/document_goodreads_test.go b/internal/webserver/document_goodreads_test.go
new file mode 100644
index 00000000..e9748b97
--- /dev/null
+++ b/internal/webserver/document_goodreads_test.go
@@ -0,0 +1,246 @@
+package webserver_test
+
+import (
+ "context"
+ "errors"
+ "io"
+ "net/http"
+ "strings"
+ "sync"
+ "testing"
+
+ "github.com/PuerkitoBio/goquery"
+ "github.com/blevesearch/bleve/v2"
+ "github.com/gofiber/fiber/v3"
+ "github.com/spf13/afero"
+ "github.com/svera/coreander/v5/internal/datasource/biblioreads"
+ "github.com/svera/coreander/v5/internal/index"
+ "github.com/svera/coreander/v5/internal/metadata"
+ "github.com/svera/coreander/v5/internal/webserver"
+ "github.com/svera/coreander/v5/internal/webserver/controller/document"
+ "github.com/svera/coreander/v5/internal/webserver/infrastructure"
+ "github.com/svera/coreander/v5/internal/webserver/model"
+)
+
+type fakeBookSource struct {
+ mu sync.Mutex
+ ids []string
+ queries []string
+ book biblioreads.BookResponse
+ results []biblioreads.SearchResult
+ err error
+}
+
+func (f *fakeBookSource) SearchBooks(_ context.Context, query string) (biblioreads.SearchBooksResponse, error) {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ f.queries = append(f.queries, query)
+ return biblioreads.SearchBooksResponse{Result: f.results}, nil
+}
+
+func (f *fakeBookSource) Book(_ context.Context, id string) (biblioreads.BookResponse, error) {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ f.ids = append(f.ids, id)
+ return f.book, f.err
+}
+
+func newGoodReadsTestApp(t *testing.T, source *fakeBookSource) (*fiber.App, *index.BleveIndexer) {
+ t.Helper()
+ db := infrastructure.Connect(":memory:", 250)
+ sqlDB, err := db.DB()
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { _ = sqlDB.Close() })
+
+ documents, err := bleve.NewMemOnly(index.CreateDocumentsMapping())
+ if err != nil {
+ t.Fatal(err)
+ }
+ authors, err := bleve.NewMemOnly(index.CreateAuthorsMapping())
+ if err != nil {
+ t.Fatal(err)
+ }
+ appFS := afero.NewMemMapFs()
+ var bookSource document.BookSource
+ idxCfg := index.Config{}
+ if source != nil {
+ bookSource = source
+ idxCfg.GoodReadsSource = source
+ }
+ idx := index.NewBleve(documents, authors, appFS, testLibraryDir, nil, idxCfg)
+ t.Cleanup(func() { _ = idx.Close() })
+
+ for _, doc := range []index.Document{
+ {
+ ID: "linked.epub", Slug: "linked",
+ Metadata: metadata.Metadata{Title: "La Reina Dragon", Format: "EPUB"},
+ GoodReadsBookURL: "/book/show/12207444-la-reina-dragon",
+ GoodReadsEnriched: true,
+ },
+ {
+ ID: "unlinked.epub", Slug: "unlinked",
+ Metadata: metadata.Metadata{Title: "Unlinked", Authors: []string{"Jane Doe", "John Doe"}, Format: "EPUB"},
+ AuthorsSlugs: []string{"jane-doe", "john-doe"},
+ },
+ {
+ ID: "unmatched.epub", Slug: "unmatched",
+ Metadata: metadata.Metadata{Title: "Unmatched", Format: "EPUB"},
+ GoodReadsEnriched: true,
+ },
+ } {
+ if err := documents.Index(doc.ID, doc); err != nil {
+ t.Fatal(err)
+ }
+ }
+
+ cfg := defaultTestConfig()
+ sender := &infrastructure.NoEmail{}
+ controllers := webserver.SetupControllers(cfg, db, idx, sender, appFS, nil, bookSource)
+ app := webserver.New(cfg, controllers, sender, idx, &model.UserRepository{DB: db})
+ t.Cleanup(func() { _ = app.Shutdown() })
+ return app, idx
+}
+
+func getBody(t *testing.T, app *fiber.App, path string) string {
+ t.Helper()
+ response, err := app.Test(mustGetRequest(t, path))
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer response.Body.Close()
+ if response.StatusCode != http.StatusOK {
+ t.Fatalf("%s: status = %d, want %d", path, response.StatusCode, http.StatusOK)
+ }
+ body, err := io.ReadAll(response.Body)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return string(body)
+}
+
+func goodReadsPlaceholders(t *testing.T, html string) *goquery.Selection {
+ t.Helper()
+ page, err := goquery.NewDocumentFromReader(strings.NewReader(html))
+ if err != nil {
+ t.Fatal(err)
+ }
+ return page.Find(`li[hx-get$="/goodreads"]`)
+}
+
+func TestDocumentDetailGoodReadsRating(t *testing.T) {
+ source := &fakeBookSource{book: biblioreads.BookResponse{Rating: "3.82", RatingCount: "2,285"}}
+ app, _ := newGoodReadsTestApp(t, source)
+
+ placeholder := goodReadsPlaceholders(t, getBody(t, app, "/documents/linked"))
+ if placeholder.Length() != 1 {
+ t.Fatalf("expected one GoodReads placeholder, got %d", placeholder.Length())
+ }
+ if url, _ := placeholder.Attr("hx-get"); url != "/documents/linked/goodreads" {
+ t.Errorf("unexpected placeholder URL %q", url)
+ }
+ if placeholder.ParentsFiltered(`[id="document-metadata-linked"]`).Length() != 1 {
+ t.Error("expected the placeholder inside the document metadata list")
+ }
+
+ page, err := goquery.NewDocumentFromReader(strings.NewReader(getBody(t, app, "/documents/linked/goodreads")))
+ if err != nil {
+ t.Fatal(err)
+ }
+ link := page.Find(`li a[href="https://www.goodreads.com/book/show/12207444-la-reina-dragon"]`)
+ if strings.TrimSpace(link.Text()) != "3.82" {
+ t.Errorf("expected rating link with 3.82, got %q", link.Text())
+ }
+ if !strings.Contains(page.Find("li").Text(), "2,285") {
+ t.Errorf("expected rating count in %q", page.Find("li").Text())
+ }
+ if len(source.ids) != 1 || source.ids[0] != "12207444-la-reina-dragon" {
+ t.Errorf("expected Book to be called with the stored id, got %q", source.ids)
+ }
+ if len(source.queries) != 0 {
+ t.Errorf("expected no search for a linked document, got %q", source.queries)
+ }
+}
+
+func TestDocumentDetailGoodReadsSearchesUnlinkedDocuments(t *testing.T) {
+ source := &fakeBookSource{
+ book: biblioreads.BookResponse{Rating: "4.10"},
+ results: []biblioreads.SearchResult{{Title: "Unlinked", Author: "Jane Doe", BookURL: "/book/show/1-unlinked", AuthorURL: "/author/show/1.Jane_Doe"}, {BookURL: "/book/show/2-other"}},
+ }
+ app, idx := newGoodReadsTestApp(t, source)
+
+ if got := goodReadsPlaceholders(t, getBody(t, app, "/documents/unlinked")).Length(); got != 1 {
+ t.Fatalf("expected a placeholder for a document not looked up yet, got %d", got)
+ }
+
+ page, err := goquery.NewDocumentFromReader(strings.NewReader(getBody(t, app, "/documents/unlinked/goodreads")))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got := strings.TrimSpace(page.Find(`a[href="https://www.goodreads.com/book/show/1-unlinked"]`).Text()); got != "4.10" {
+ t.Errorf("expected rating link with 4.10, got %q", got)
+ }
+ if len(source.queries) != 1 || source.queries[0] != "Unlinked Jane Doe" {
+ t.Errorf("expected a single search with title and first author, got %q", source.queries)
+ }
+ if len(source.ids) != 1 || source.ids[0] != "1-unlinked" {
+ t.Errorf("expected Book to be called with the found id, got %q", source.ids)
+ }
+
+ stored, err := idx.Document("unlinked")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if stored.GoodReadsBookURL != "/book/show/1-unlinked" || stored.GoodReadsAuthorURL != "/author/show/1.Jane_Doe" || !stored.GoodReadsEnriched {
+ t.Errorf("expected the found link to be stored, got %+v", stored)
+ }
+
+ if err := idx.EnrichGoodReads(10); err != nil {
+ t.Fatal(err)
+ }
+ getBody(t, app, "/documents/unlinked/goodreads")
+ if len(source.queries) != 1 {
+ t.Errorf("expected the stored link to be reused without searching again, got %q", source.queries)
+ }
+}
+
+func TestDocumentDetailGoodReadsSkipsUnmatchedDocuments(t *testing.T) {
+ source := &fakeBookSource{book: biblioreads.BookResponse{Rating: "3.82"}}
+ app, _ := newGoodReadsTestApp(t, source)
+ if got := goodReadsPlaceholders(t, getBody(t, app, "/documents/unmatched")).Length(); got != 0 {
+ t.Errorf("expected no placeholder for a document already looked up without match, got %d", got)
+ }
+ if body := strings.TrimSpace(getBody(t, app, "/documents/unmatched/goodreads")); body != "" {
+ t.Errorf("expected empty response, got %q", body)
+ }
+ if len(source.queries) != 0 || len(source.ids) != 0 {
+ t.Errorf("expected no calls, got queries %q and ids %q", source.queries, source.ids)
+ }
+}
+
+func TestDocumentDetailGoodReadsRatingRendersNothingWhenUnavailable(t *testing.T) {
+ for name, source := range map[string]*fakeBookSource{
+ "error": {err: errors.New("unavailable")},
+ "no rating": {},
+ } {
+ t.Run(name, func(t *testing.T) {
+ app, _ := newGoodReadsTestApp(t, source)
+ if body := strings.TrimSpace(getBody(t, app, "/documents/linked/goodreads")); body != "" {
+ t.Errorf("expected empty response, got %q", body)
+ }
+ })
+ }
+}
+
+func TestDocumentDetailGoodReadsDisabled(t *testing.T) {
+ app, _ := newGoodReadsTestApp(t, nil)
+ for _, slug := range []string{"linked", "unlinked"} {
+ if got := goodReadsPlaceholders(t, getBody(t, app, "/documents/"+slug)).Length(); got != 0 {
+ t.Errorf("%s: expected no placeholder when GoodReads is disabled, got %d", slug, got)
+ }
+ if body := strings.TrimSpace(getBody(t, app, "/documents/"+slug+"/goodreads")); body != "" {
+ t.Errorf("%s: expected empty response, got %q", slug, body)
+ }
+ }
+}
diff --git a/internal/webserver/embedded/js/reader-reading-mode.js b/internal/webserver/embedded/js/reader-reading-mode.js
new file mode 100644
index 00000000..376b5f98
--- /dev/null
+++ b/internal/webserver/embedded/js/reader-reading-mode.js
@@ -0,0 +1,33 @@
+export function bindReaderReadingMode(view) {
+ const document = view.ownerDocument
+ const content = document.createElement('main')
+ content.id = 'reader-reading-content'
+ content.hidden = true
+ document.body.append(content)
+ let previousDocuments = []
+
+ view.addEventListener('relocate', () => {
+ const documents = view.renderer.getContents().map(({ doc }) => doc).filter(Boolean)
+ if (documents.length === previousDocuments.length &&
+ documents.every((doc, index) => doc === previousDocuments[index])) return
+
+ const paragraphs = []
+ for (const doc of documents) {
+ // Use rendered text, not EPUB markup, scripts, hidden notes, or reader overlays.
+ const text = doc.body?.innerText ??
+ [...doc.querySelectorAll('text')].map(node => node.textContent).join('\n')
+ for (const line of text.split(/\n+/)) {
+ if (!line.trim()) continue
+ const paragraph = document.createElement('p')
+ paragraph.textContent = line.trim()
+ paragraph.lang = doc.documentElement.lang
+ paragraph.dir = doc.documentElement.dir
+ paragraphs.push(paragraph)
+ }
+ }
+ content.replaceChildren(...paragraphs)
+ content.hidden = paragraphs.length === 0
+ previousDocuments = documents
+ })
+ return content
+}
diff --git a/internal/webserver/embedded/translations/de.yml b/internal/webserver/embedded/translations/de.yml
index de06e22e..6d01b874 100644
--- a/internal/webserver/embedded/translations/de.yml
+++ b/internal/webserver/embedded/translations/de.yml
@@ -404,3 +404,5 @@
"A newer version of this document is available.": "Eine neuere Version dieses Dokuments ist verfügbar."
"This document is no longer available. Its offline copy has been removed.": "Dieses Dokument ist nicht mehr verfügbar. Seine Offline-Kopie wurde entfernt."
"Reload document": "Dokument neu laden"
+"Goodreads rating": "Goodreads-Bewertung"
+"%s ratings": "%s Bewertungen"
diff --git a/internal/webserver/embedded/translations/es.yml b/internal/webserver/embedded/translations/es.yml
index a14d6f25..04a2e6c6 100644
--- a/internal/webserver/embedded/translations/es.yml
+++ b/internal/webserver/embedded/translations/es.yml
@@ -400,3 +400,5 @@
"A newer version of this document is available.": "Hay una versión más reciente de este documento disponible."
"This document is no longer available. Its offline copy has been removed.": "Este documento ya no está disponible. Se ha eliminado su copia sin conexión."
"Reload document": "Recargar documento"
+"Goodreads rating": "Puntuación en Goodreads"
+"%s ratings": "%s valoraciones"
diff --git a/internal/webserver/embedded/translations/fr.yml b/internal/webserver/embedded/translations/fr.yml
index 71859694..55442a89 100644
--- a/internal/webserver/embedded/translations/fr.yml
+++ b/internal/webserver/embedded/translations/fr.yml
@@ -402,3 +402,5 @@
"A newer version of this document is available.": "Une version plus récente de ce document est disponible."
"This document is no longer available. Its offline copy has been removed.": "Ce document n'est plus disponible. Sa copie hors connexion a été supprimée."
"Reload document": "Recharger le document"
+"Goodreads rating": "Note Goodreads"
+"%s ratings": "%s notes"
diff --git a/internal/webserver/embedded/translations/ru.yml b/internal/webserver/embedded/translations/ru.yml
index ae8fbbd6..506656f7 100644
--- a/internal/webserver/embedded/translations/ru.yml
+++ b/internal/webserver/embedded/translations/ru.yml
@@ -396,3 +396,5 @@
"A newer version of this document is available.": "Доступна новая версия этого документа."
"This document is no longer available. Its offline copy has been removed.": "Этот документ больше недоступен. Его копия для чтения без подключения к сети удалена."
"Reload document": "Перезагрузить документ"
+"Goodreads rating": "Рейтинг Goodreads"
+"%s ratings": "Оценок: %s"
diff --git a/internal/webserver/embedded/views/document/detail.html b/internal/webserver/embedded/views/document/detail.html
index a309e652..7bba26ee 100644
--- a/internal/webserver/embedded/views/document/detail.html
+++ b/internal/webserver/embedded/views/document/detail.html
@@ -32,7 +32,7 @@