pinakes-core: update remaining modules and tests

Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I9e0ff5ea33a5cf697473423e88f167ce6a6a6964
This commit is contained in:
raf 2026-03-08 00:42:29 +03:00
commit 3d9f8933d2
Signed by: NotAShelf
GPG key ID: 29D95B64378DB4BF
44 changed files with 1207 additions and 578 deletions

View file

@ -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 {

View file

@ -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"
}

View file

@ -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);

View file

@ -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,

View file

@ -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);