gutenberg: one bind() for the search and redeem paths
Test / cargo test (push) Failing after 2m47s

compute_outcomes still passed the identifier-only lookup's Option<String> to
into_discovery_record, which now takes the binding (CI: E0308). Both paths now
call bind(): the identifier, else the title match for an etext Open Library
lists under no work, so a redeemed record is bound exactly as its search record
was.
This commit is contained in:
Manielle
2026-10-19 19:38:22 +00:00
parent d177e7b710
commit efba2f2825
+25 -17
View File
@@ -44,6 +44,26 @@ struct Binding {
by_title: bool, by_title: bool,
} }
/// One etext's binding: the identifier Open Library records, else — only for an
/// etext it lists under no work — the title match. Shared by the search path and
/// the redeem path so the two cannot bind the same book differently.
async fn bind(
client: &OpenLibraryClient,
id: u64,
title: &str,
author: &str,
deadline: std::time::Duration,
) -> Result<Option<Binding>, GatewayError> {
Ok(match client.gutenberg_etext(id, deadline).await? {
Etext::One(work) => Some(Binding { work, by_title: false }),
Etext::Several => None,
Etext::Unlisted => client
.title_work(title, author, deadline)
.await?
.map(|work| Binding { work, by_title: true }),
})
}
/// Canonical Gutendex base URL. Overridable via [`GutenbergPlugin::with_base_url`] /// Canonical Gutendex base URL. Overridable via [`GutenbergPlugin::with_base_url`]
/// for tests + private mirrors. /// for tests + private mirrors.
const DEFAULT_BASE_URL: &str = "https://gutendex.com"; const DEFAULT_BASE_URL: &str = "https://gutendex.com";
@@ -451,19 +471,7 @@ impl GutenbergPlugin {
.collect(); .collect();
stream::iter(wanted) stream::iter(wanted)
.map(|(id, title, author)| async move { .map(|(id, title, author)| async move {
// The identifier first; a title match only for an etext Open let lookup = bind(client, id, &title, &author, openlibrary_client::BINDING_DEADLINE);
// Library lists under no work at all.
let lookup = async {
let deadline = openlibrary_client::BINDING_DEADLINE;
Ok::<_, GatewayError>(match client.gutenberg_etext(id, deadline).await? {
Etext::One(work) => Some(Binding { work, by_title: false }),
Etext::Several => None,
Etext::Unlisted => client
.title_work(&title, &author, deadline)
.await?
.map(|work| Binding { work, by_title: true }),
})
};
match tokio::time::timeout(BINDING_TIMEOUT, lookup).await { match tokio::time::timeout(BINDING_TIMEOUT, lookup).await {
Ok(Ok(binding)) => binding, Ok(Ok(binding)) => binding,
Ok(Err(e)) => { Ok(Err(e)) => {
@@ -628,9 +636,9 @@ impl FeederPlugin for GutenbergPlugin {
// Not user-facing, so the binding lookup may queue for the full search // Not user-facing, so the binding lookup may queue for the full search
// deadline. A failure still leaves the record unbound, never fails the // deadline. A failure still leaves the record unbound, never fails the
// compute. // compute.
let author = book.authors.first().map(|a| a.name.clone()).unwrap_or_default();
let work = match self.openlibrary.as_ref() { let work = match self.openlibrary.as_ref() {
Some(ol) => ol Some(ol) => bind(ol, book.id, &book.title, &author, openlibrary_client::SEARCH_DEADLINE)
.gutenberg_work(book.id, openlibrary_client::SEARCH_DEADLINE)
.await .await
.unwrap_or_else(|e| { .unwrap_or_else(|e| {
debug!( debug!(
@@ -704,8 +712,8 @@ impl FeederPlugin for GutenbergPlugin {
/// Convert a `GutendexBook` into our wire-level `DiscoveryRecord`. /// Convert a `GutendexBook` into our wire-level `DiscoveryRecord`.
/// ///
/// `openlibrary_work` is the binding from [`OpenLibraryClient::gutenberg_work`]: /// `openlibrary_work` is the binding from [`bind`]: the one work Open Library
/// `Some` only when Open Library records exactly one work for this etext. /// records for this etext, or, when it records none, the title match.
fn into_discovery_record( fn into_discovery_record(
book: GutendexBook, book: GutendexBook,
cover_cid: Option<String>, cover_cid: Option<String>,