pinakes-core: update remaining modules and tests
Signed-off-by: NotAShelf <raf@notashelf.dev> Change-Id: I9e0ff5ea33a5cf697473423e88f167ce6a6a6964
This commit is contained in:
parent
c8425a4c34
commit
3d9f8933d2
44 changed files with 1207 additions and 578 deletions
|
|
@ -13,13 +13,15 @@ use crate::{
|
|||
model::MediaItem,
|
||||
};
|
||||
|
||||
/// Book enricher that tries OpenLibrary first, then falls back to Google Books
|
||||
/// Book enricher that tries `OpenLibrary` first, then falls back to Google
|
||||
/// Books
|
||||
pub struct BookEnricher {
|
||||
openlibrary: OpenLibraryClient,
|
||||
googlebooks: GoogleBooksClient,
|
||||
}
|
||||
|
||||
impl BookEnricher {
|
||||
#[must_use]
|
||||
pub fn new(google_api_key: Option<String>) -> Self {
|
||||
Self {
|
||||
openlibrary: OpenLibraryClient::new(),
|
||||
|
|
@ -27,7 +29,11 @@ impl BookEnricher {
|
|||
}
|
||||
}
|
||||
|
||||
/// Try to enrich from OpenLibrary first
|
||||
/// Try to enrich from `OpenLibrary` first
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the metadata cannot be serialized.
|
||||
pub async fn try_openlibrary(
|
||||
&self,
|
||||
isbn: &str,
|
||||
|
|
@ -35,7 +41,7 @@ impl BookEnricher {
|
|||
match self.openlibrary.fetch_by_isbn(isbn).await {
|
||||
Ok(book) => {
|
||||
let metadata_json = serde_json::to_string(&book).map_err(|e| {
|
||||
PinakesError::External(format!("Failed to serialize metadata: {}", e))
|
||||
PinakesError::External(format!("Failed to serialize metadata: {e}"))
|
||||
})?;
|
||||
|
||||
Ok(Some(ExternalMetadata {
|
||||
|
|
@ -53,6 +59,10 @@ impl BookEnricher {
|
|||
}
|
||||
|
||||
/// Try to enrich from Google Books
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the metadata cannot be serialized.
|
||||
pub async fn try_googlebooks(
|
||||
&self,
|
||||
isbn: &str,
|
||||
|
|
@ -61,7 +71,7 @@ impl BookEnricher {
|
|||
Ok(books) if !books.is_empty() => {
|
||||
let book = &books[0];
|
||||
let metadata_json = serde_json::to_string(book).map_err(|e| {
|
||||
PinakesError::External(format!("Failed to serialize metadata: {}", e))
|
||||
PinakesError::External(format!("Failed to serialize metadata: {e}"))
|
||||
})?;
|
||||
|
||||
Ok(Some(ExternalMetadata {
|
||||
|
|
@ -79,6 +89,10 @@ impl BookEnricher {
|
|||
}
|
||||
|
||||
/// Try to enrich by searching with title and author
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the metadata cannot be serialized.
|
||||
pub async fn enrich_by_search(
|
||||
&self,
|
||||
title: &str,
|
||||
|
|
@ -89,7 +103,7 @@ impl BookEnricher {
|
|||
&& let Some(result) = results.first()
|
||||
{
|
||||
let metadata_json = serde_json::to_string(result).map_err(|e| {
|
||||
PinakesError::External(format!("Failed to serialize metadata: {}", e))
|
||||
PinakesError::External(format!("Failed to serialize metadata: {e}"))
|
||||
})?;
|
||||
|
||||
return Ok(Some(ExternalMetadata {
|
||||
|
|
@ -108,7 +122,7 @@ impl BookEnricher {
|
|||
&& let Some(book) = results.first()
|
||||
{
|
||||
let metadata_json = serde_json::to_string(book).map_err(|e| {
|
||||
PinakesError::External(format!("Failed to serialize metadata: {}", e))
|
||||
PinakesError::External(format!("Failed to serialize metadata: {e}"))
|
||||
})?;
|
||||
|
||||
return Ok(Some(ExternalMetadata {
|
||||
|
|
@ -158,7 +172,8 @@ impl MetadataEnricher for BookEnricher {
|
|||
}
|
||||
}
|
||||
|
||||
/// Calculate confidence score for OpenLibrary metadata
|
||||
/// Calculate confidence score for `OpenLibrary` metadata
|
||||
#[must_use]
|
||||
pub fn calculate_openlibrary_confidence(
|
||||
book: &super::openlibrary::OpenLibraryBook,
|
||||
) -> f64 {
|
||||
|
|
@ -187,6 +202,7 @@ pub fn calculate_openlibrary_confidence(
|
|||
}
|
||||
|
||||
/// Calculate confidence score for Google Books metadata
|
||||
#[must_use]
|
||||
pub fn calculate_googlebooks_confidence(
|
||||
info: &super::googlebooks::VolumeInfo,
|
||||
) -> f64 {
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
use std::fmt::Write as _;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::error::{PinakesError, Result};
|
||||
|
|
@ -9,30 +11,33 @@ pub struct GoogleBooksClient {
|
|||
}
|
||||
|
||||
impl GoogleBooksClient {
|
||||
/// Create a new `GoogleBooksClient`.
|
||||
#[must_use]
|
||||
pub fn new(api_key: Option<String>) -> Self {
|
||||
Self {
|
||||
client: reqwest::Client::builder()
|
||||
.user_agent("Pinakes/1.0")
|
||||
.timeout(std::time::Duration::from_secs(10))
|
||||
.build()
|
||||
.expect("Failed to build HTTP client"),
|
||||
api_key,
|
||||
}
|
||||
let client = reqwest::Client::builder()
|
||||
.user_agent("Pinakes/1.0")
|
||||
.timeout(std::time::Duration::from_secs(10))
|
||||
.build()
|
||||
.unwrap_or_else(|_| reqwest::Client::new());
|
||||
Self { client, api_key }
|
||||
}
|
||||
|
||||
/// Fetch book metadata by ISBN
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the HTTP request fails or the response cannot be
|
||||
/// parsed.
|
||||
pub async fn fetch_by_isbn(&self, isbn: &str) -> Result<Vec<GoogleBook>> {
|
||||
let mut url = format!(
|
||||
"https://www.googleapis.com/books/v1/volumes?q=isbn:{}",
|
||||
isbn
|
||||
);
|
||||
let mut url =
|
||||
format!("https://www.googleapis.com/books/v1/volumes?q=isbn:{isbn}");
|
||||
|
||||
if let Some(ref key) = self.api_key {
|
||||
url.push_str(&format!("&key={}", key));
|
||||
let _ = write!(url, "&key={key}");
|
||||
}
|
||||
|
||||
let response = self.client.get(&url).send().await.map_err(|e| {
|
||||
PinakesError::External(format!("Google Books request failed: {}", e))
|
||||
PinakesError::External(format!("Google Books request failed: {e}"))
|
||||
})?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
|
|
@ -44,8 +49,7 @@ impl GoogleBooksClient {
|
|||
|
||||
let volumes: GoogleBooksResponse = response.json().await.map_err(|e| {
|
||||
PinakesError::External(format!(
|
||||
"Failed to parse Google Books response: {}",
|
||||
e
|
||||
"Failed to parse Google Books response: {e}"
|
||||
))
|
||||
})?;
|
||||
|
||||
|
|
@ -53,6 +57,11 @@ impl GoogleBooksClient {
|
|||
}
|
||||
|
||||
/// Search for books by title and author
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the HTTP request fails or the response cannot be
|
||||
/// parsed.
|
||||
pub async fn search(
|
||||
&self,
|
||||
title: &str,
|
||||
|
|
@ -61,20 +70,19 @@ impl GoogleBooksClient {
|
|||
let mut query = format!("intitle:{}", urlencoding::encode(title));
|
||||
|
||||
if let Some(author) = author {
|
||||
query.push_str(&format!("+inauthor:{}", urlencoding::encode(author)));
|
||||
let _ = write!(query, "+inauthor:{}", urlencoding::encode(author));
|
||||
}
|
||||
|
||||
let mut url = format!(
|
||||
"https://www.googleapis.com/books/v1/volumes?q={}&maxResults=5",
|
||||
query
|
||||
"https://www.googleapis.com/books/v1/volumes?q={query}&maxResults=5"
|
||||
);
|
||||
|
||||
if let Some(ref key) = self.api_key {
|
||||
url.push_str(&format!("&key={}", key));
|
||||
let _ = write!(url, "&key={key}");
|
||||
}
|
||||
|
||||
let response = self.client.get(&url).send().await.map_err(|e| {
|
||||
PinakesError::External(format!("Google Books search failed: {}", e))
|
||||
PinakesError::External(format!("Google Books search failed: {e}"))
|
||||
})?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
|
|
@ -85,13 +93,18 @@ impl GoogleBooksClient {
|
|||
}
|
||||
|
||||
let volumes: GoogleBooksResponse = response.json().await.map_err(|e| {
|
||||
PinakesError::External(format!("Failed to parse search results: {}", e))
|
||||
PinakesError::External(format!("Failed to parse search results: {e}"))
|
||||
})?;
|
||||
|
||||
Ok(volumes.items)
|
||||
}
|
||||
|
||||
/// Download cover image from Google Books
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the HTTP request fails or the response cannot be
|
||||
/// read.
|
||||
pub async fn fetch_cover(&self, image_link: &str) -> Result<Vec<u8>> {
|
||||
// Replace thumbnail link with higher resolution if possible
|
||||
let high_res_link = image_link
|
||||
|
|
@ -100,7 +113,7 @@ impl GoogleBooksClient {
|
|||
|
||||
let response =
|
||||
self.client.get(&high_res_link).send().await.map_err(|e| {
|
||||
PinakesError::External(format!("Cover download failed: {}", e))
|
||||
PinakesError::External(format!("Cover download failed: {e}"))
|
||||
})?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
|
|
@ -111,7 +124,7 @@ impl GoogleBooksClient {
|
|||
}
|
||||
|
||||
response.bytes().await.map(|b| b.to_vec()).map_err(|e| {
|
||||
PinakesError::External(format!("Failed to read cover data: {}", e))
|
||||
PinakesError::External(format!("Failed to read cover data: {e}"))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -201,6 +214,7 @@ pub struct ImageLinks {
|
|||
|
||||
impl ImageLinks {
|
||||
/// Get the best available image link (highest resolution)
|
||||
#[must_use]
|
||||
pub fn best_link(&self) -> Option<&String> {
|
||||
self
|
||||
.extra_large
|
||||
|
|
@ -223,11 +237,13 @@ pub struct IndustryIdentifier {
|
|||
|
||||
impl IndustryIdentifier {
|
||||
/// Check if this is an ISBN-13
|
||||
#[must_use]
|
||||
pub fn is_isbn13(&self) -> bool {
|
||||
self.identifier_type == "ISBN_13"
|
||||
}
|
||||
|
||||
/// Check if this is an ISBN-10
|
||||
#[must_use]
|
||||
pub fn is_isbn10(&self) -> bool {
|
||||
self.identifier_type == "ISBN_10"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,13 +18,16 @@ pub struct LastFmEnricher {
|
|||
}
|
||||
|
||||
impl LastFmEnricher {
|
||||
/// Create a new `LastFmEnricher`.
|
||||
#[must_use]
|
||||
pub fn new(api_key: String) -> Self {
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(10))
|
||||
.connect_timeout(Duration::from_secs(5))
|
||||
.build()
|
||||
.unwrap_or_else(|_| reqwest::Client::new());
|
||||
Self {
|
||||
client: reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(10))
|
||||
.connect_timeout(Duration::from_secs(5))
|
||||
.build()
|
||||
.expect("failed to build HTTP client with configured timeouts"),
|
||||
client,
|
||||
api_key,
|
||||
base_url: "https://ws.audioscrobbler.com/2.0".to_string(),
|
||||
}
|
||||
|
|
@ -87,9 +90,8 @@ impl MetadataEnricher for LastFmEnricher {
|
|||
return Ok(None);
|
||||
}
|
||||
|
||||
let track = match json.get("track") {
|
||||
Some(t) => t,
|
||||
None => return Ok(None),
|
||||
let Some(track) = json.get("track") else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let mbid = track.get("mbid").and_then(|m| m.as_str()).map(String::from);
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
use std::fmt::Write as _;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::error::{PinakesError, Result};
|
||||
|
||||
/// OpenLibrary API client for book metadata enrichment
|
||||
/// `OpenLibrary` API client for book metadata enrichment
|
||||
pub struct OpenLibraryClient {
|
||||
client: reqwest::Client,
|
||||
base_url: String,
|
||||
|
|
@ -15,23 +17,31 @@ impl Default for OpenLibraryClient {
|
|||
}
|
||||
|
||||
impl OpenLibraryClient {
|
||||
/// Create a new `OpenLibraryClient`.
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
let client = reqwest::Client::builder()
|
||||
.user_agent("Pinakes/1.0")
|
||||
.timeout(std::time::Duration::from_secs(10))
|
||||
.build()
|
||||
.unwrap_or_else(|_| reqwest::Client::new());
|
||||
Self {
|
||||
client: reqwest::Client::builder()
|
||||
.user_agent("Pinakes/1.0")
|
||||
.timeout(std::time::Duration::from_secs(10))
|
||||
.build()
|
||||
.expect("Failed to build HTTP client"),
|
||||
client,
|
||||
base_url: "https://openlibrary.org".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch book metadata by ISBN
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the HTTP request fails or the response cannot be
|
||||
/// parsed.
|
||||
pub async fn fetch_by_isbn(&self, isbn: &str) -> Result<OpenLibraryBook> {
|
||||
let url = format!("{}/isbn/{}.json", self.base_url, isbn);
|
||||
|
||||
let response = self.client.get(&url).send().await.map_err(|e| {
|
||||
PinakesError::External(format!("OpenLibrary request failed: {}", e))
|
||||
PinakesError::External(format!("OpenLibrary request failed: {e}"))
|
||||
})?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
|
|
@ -43,13 +53,17 @@ impl OpenLibraryClient {
|
|||
|
||||
response.json::<OpenLibraryBook>().await.map_err(|e| {
|
||||
PinakesError::External(format!(
|
||||
"Failed to parse OpenLibrary response: {}",
|
||||
e
|
||||
"Failed to parse OpenLibrary response: {e}"
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
/// Search for books by title and author
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the HTTP request fails or the response cannot be
|
||||
/// parsed.
|
||||
pub async fn search(
|
||||
&self,
|
||||
title: &str,
|
||||
|
|
@ -62,13 +76,13 @@ impl OpenLibraryClient {
|
|||
);
|
||||
|
||||
if let Some(author) = author {
|
||||
url.push_str(&format!("&author={}", urlencoding::encode(author)));
|
||||
let _ = write!(url, "&author={}", urlencoding::encode(author));
|
||||
}
|
||||
|
||||
url.push_str("&limit=5");
|
||||
|
||||
let response = self.client.get(&url).send().await.map_err(|e| {
|
||||
PinakesError::External(format!("OpenLibrary search failed: {}", e))
|
||||
PinakesError::External(format!("OpenLibrary search failed: {e}"))
|
||||
})?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
|
|
@ -80,13 +94,18 @@ impl OpenLibraryClient {
|
|||
|
||||
let search_response: OpenLibrarySearchResponse =
|
||||
response.json().await.map_err(|e| {
|
||||
PinakesError::External(format!("Failed to parse search results: {}", e))
|
||||
PinakesError::External(format!("Failed to parse search results: {e}"))
|
||||
})?;
|
||||
|
||||
Ok(search_response.docs)
|
||||
}
|
||||
|
||||
/// Fetch cover image by cover ID
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the HTTP request fails or the response cannot be
|
||||
/// read.
|
||||
pub async fn fetch_cover(
|
||||
&self,
|
||||
cover_id: i64,
|
||||
|
|
@ -98,13 +117,11 @@ impl OpenLibraryClient {
|
|||
CoverSize::Large => "L",
|
||||
};
|
||||
|
||||
let url = format!(
|
||||
"https://covers.openlibrary.org/b/id/{}-{}.jpg",
|
||||
cover_id, size_str
|
||||
);
|
||||
let url =
|
||||
format!("https://covers.openlibrary.org/b/id/{cover_id}-{size_str}.jpg");
|
||||
|
||||
let response = self.client.get(&url).send().await.map_err(|e| {
|
||||
PinakesError::External(format!("Cover download failed: {}", e))
|
||||
PinakesError::External(format!("Cover download failed: {e}"))
|
||||
})?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
|
|
@ -115,11 +132,16 @@ impl OpenLibraryClient {
|
|||
}
|
||||
|
||||
response.bytes().await.map(|b| b.to_vec()).map_err(|e| {
|
||||
PinakesError::External(format!("Failed to read cover data: {}", e))
|
||||
PinakesError::External(format!("Failed to read cover data: {e}"))
|
||||
})
|
||||
}
|
||||
|
||||
/// Fetch cover by ISBN
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the HTTP request fails or the response cannot be
|
||||
/// read.
|
||||
pub async fn fetch_cover_by_isbn(
|
||||
&self,
|
||||
isbn: &str,
|
||||
|
|
@ -131,13 +153,11 @@ impl OpenLibraryClient {
|
|||
CoverSize::Large => "L",
|
||||
};
|
||||
|
||||
let url = format!(
|
||||
"https://covers.openlibrary.org/b/isbn/{}-{}.jpg",
|
||||
isbn, size_str
|
||||
);
|
||||
let url =
|
||||
format!("https://covers.openlibrary.org/b/isbn/{isbn}-{size_str}.jpg");
|
||||
|
||||
let response = self.client.get(&url).send().await.map_err(|e| {
|
||||
PinakesError::External(format!("Cover download failed: {}", e))
|
||||
PinakesError::External(format!("Cover download failed: {e}"))
|
||||
})?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
|
|
@ -148,7 +168,7 @@ impl OpenLibraryClient {
|
|||
}
|
||||
|
||||
response.bytes().await.map(|b| b.to_vec()).map_err(|e| {
|
||||
PinakesError::External(format!("Failed to read cover data: {}", e))
|
||||
PinakesError::External(format!("Failed to read cover data: {e}"))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -220,6 +240,7 @@ pub enum StringOrObject {
|
|||
}
|
||||
|
||||
impl StringOrObject {
|
||||
#[must_use]
|
||||
pub fn as_str(&self) -> &str {
|
||||
match self {
|
||||
Self::String(s) => s,
|
||||
|
|
|
|||
|
|
@ -18,6 +18,13 @@ pub struct TmdbEnricher {
|
|||
}
|
||||
|
||||
impl TmdbEnricher {
|
||||
/// Create a new `TMDb` enricher.
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// Panics if the HTTP client cannot be built (programming error in client
|
||||
/// configuration).
|
||||
#[must_use]
|
||||
pub fn new(api_key: String) -> Self {
|
||||
Self {
|
||||
client: reqwest::Client::builder()
|
||||
|
|
@ -50,7 +57,7 @@ impl MetadataEnricher for TmdbEnricher {
|
|||
.get(&url)
|
||||
.query(&[
|
||||
("api_key", &self.api_key),
|
||||
("query", &title.to_string()),
|
||||
("query", &title.clone()),
|
||||
("page", &"1".to_string()),
|
||||
])
|
||||
.send()
|
||||
|
|
@ -85,7 +92,7 @@ impl MetadataEnricher for TmdbEnricher {
|
|||
})?;
|
||||
|
||||
let results = json.get("results").and_then(|r| r.as_array());
|
||||
if results.is_none_or(|r| r.is_empty()) {
|
||||
if results.is_none_or(std::vec::Vec::is_empty) {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
|
|
@ -93,13 +100,14 @@ impl MetadataEnricher for TmdbEnricher {
|
|||
return Ok(None);
|
||||
};
|
||||
let movie = &results[0];
|
||||
let external_id = match movie.get("id").and_then(|id| id.as_i64()) {
|
||||
let external_id = match movie.get("id").and_then(serde_json::Value::as_i64)
|
||||
{
|
||||
Some(id) => id.to_string(),
|
||||
None => return Ok(None),
|
||||
};
|
||||
let popularity = movie
|
||||
.get("popularity")
|
||||
.and_then(|p| p.as_f64())
|
||||
.and_then(serde_json::Value::as_f64)
|
||||
.unwrap_or(0.0);
|
||||
// Normalize popularity to 0-1 range (TMDB popularity can be very high)
|
||||
let confidence = (popularity / 100.0).min(1.0);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue