treewide: fix various UI bugs; optimize crypto dependencies & format
Signed-off-by: NotAShelf <raf@notashelf.dev> Change-Id: If8fe8b38c1d9c4fecd40ff71f88d2ae06a6a6964
This commit is contained in:
parent
764aafa88d
commit
3ccddce7fd
178 changed files with 58342 additions and 54241 deletions
|
|
@ -1,237 +1,253 @@
|
|||
use chrono::Utc;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::{PinakesError, Result};
|
||||
use crate::model::MediaItem;
|
||||
|
||||
use super::googlebooks::GoogleBooksClient;
|
||||
use super::openlibrary::OpenLibraryClient;
|
||||
use super::{EnrichmentSourceType, ExternalMetadata, MetadataEnricher};
|
||||
use super::{
|
||||
EnrichmentSourceType,
|
||||
ExternalMetadata,
|
||||
MetadataEnricher,
|
||||
googlebooks::GoogleBooksClient,
|
||||
openlibrary::OpenLibraryClient,
|
||||
};
|
||||
use crate::{
|
||||
error::{PinakesError, Result},
|
||||
model::MediaItem,
|
||||
};
|
||||
|
||||
/// Book enricher that tries OpenLibrary first, then falls back to Google Books
|
||||
pub struct BookEnricher {
|
||||
openlibrary: OpenLibraryClient,
|
||||
googlebooks: GoogleBooksClient,
|
||||
openlibrary: OpenLibraryClient,
|
||||
googlebooks: GoogleBooksClient,
|
||||
}
|
||||
|
||||
impl BookEnricher {
|
||||
pub fn new(google_api_key: Option<String>) -> Self {
|
||||
Self {
|
||||
openlibrary: OpenLibraryClient::new(),
|
||||
googlebooks: GoogleBooksClient::new(google_api_key),
|
||||
}
|
||||
pub fn new(google_api_key: Option<String>) -> Self {
|
||||
Self {
|
||||
openlibrary: OpenLibraryClient::new(),
|
||||
googlebooks: GoogleBooksClient::new(google_api_key),
|
||||
}
|
||||
}
|
||||
|
||||
/// Try to enrich from OpenLibrary first
|
||||
pub async fn try_openlibrary(
|
||||
&self,
|
||||
isbn: &str,
|
||||
) -> Result<Option<ExternalMetadata>> {
|
||||
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))
|
||||
})?;
|
||||
|
||||
Ok(Some(ExternalMetadata {
|
||||
id: Uuid::new_v4(),
|
||||
media_id: crate::model::MediaId(Uuid::nil()), // Will be set by caller
|
||||
source: EnrichmentSourceType::OpenLibrary,
|
||||
external_id: None,
|
||||
metadata_json,
|
||||
confidence: calculate_openlibrary_confidence(&book),
|
||||
last_updated: Utc::now(),
|
||||
}))
|
||||
},
|
||||
Err(_) => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// Try to enrich from Google Books
|
||||
pub async fn try_googlebooks(
|
||||
&self,
|
||||
isbn: &str,
|
||||
) -> Result<Option<ExternalMetadata>> {
|
||||
match self.googlebooks.fetch_by_isbn(isbn).await {
|
||||
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))
|
||||
})?;
|
||||
|
||||
Ok(Some(ExternalMetadata {
|
||||
id: Uuid::new_v4(),
|
||||
media_id: crate::model::MediaId(Uuid::nil()), // Will be set by caller
|
||||
source: EnrichmentSourceType::GoogleBooks,
|
||||
external_id: Some(book.id.clone()),
|
||||
metadata_json,
|
||||
confidence: calculate_googlebooks_confidence(&book.volume_info),
|
||||
last_updated: Utc::now(),
|
||||
}))
|
||||
},
|
||||
_ => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// Try to enrich by searching with title and author
|
||||
pub async fn enrich_by_search(
|
||||
&self,
|
||||
title: &str,
|
||||
author: Option<&str>,
|
||||
) -> Result<Option<ExternalMetadata>> {
|
||||
// Try OpenLibrary search first
|
||||
if let Ok(results) = self.openlibrary.search(title, author).await
|
||||
&& let Some(result) = results.first()
|
||||
{
|
||||
let metadata_json = serde_json::to_string(result).map_err(|e| {
|
||||
PinakesError::External(format!("Failed to serialize metadata: {}", e))
|
||||
})?;
|
||||
|
||||
return Ok(Some(ExternalMetadata {
|
||||
id: Uuid::new_v4(),
|
||||
media_id: crate::model::MediaId(Uuid::nil()),
|
||||
source: EnrichmentSourceType::OpenLibrary,
|
||||
external_id: result.key.clone(),
|
||||
metadata_json,
|
||||
confidence: 0.6, // Lower confidence for search results
|
||||
last_updated: Utc::now(),
|
||||
}));
|
||||
}
|
||||
|
||||
/// Try to enrich from OpenLibrary first
|
||||
pub async fn try_openlibrary(&self, isbn: &str) -> Result<Option<ExternalMetadata>> {
|
||||
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))
|
||||
})?;
|
||||
// Fall back to Google Books
|
||||
if let Ok(results) = self.googlebooks.search(title, author).await
|
||||
&& let Some(book) = results.first()
|
||||
{
|
||||
let metadata_json = serde_json::to_string(book).map_err(|e| {
|
||||
PinakesError::External(format!("Failed to serialize metadata: {}", e))
|
||||
})?;
|
||||
|
||||
Ok(Some(ExternalMetadata {
|
||||
id: Uuid::new_v4(),
|
||||
media_id: crate::model::MediaId(Uuid::nil()), // Will be set by caller
|
||||
source: EnrichmentSourceType::OpenLibrary,
|
||||
external_id: None,
|
||||
metadata_json,
|
||||
confidence: calculate_openlibrary_confidence(&book),
|
||||
last_updated: Utc::now(),
|
||||
}))
|
||||
}
|
||||
Err(_) => Ok(None),
|
||||
}
|
||||
return Ok(Some(ExternalMetadata {
|
||||
id: Uuid::new_v4(),
|
||||
media_id: crate::model::MediaId(Uuid::nil()),
|
||||
source: EnrichmentSourceType::GoogleBooks,
|
||||
external_id: Some(book.id.clone()),
|
||||
metadata_json,
|
||||
confidence: 0.6,
|
||||
last_updated: Utc::now(),
|
||||
}));
|
||||
}
|
||||
|
||||
/// Try to enrich from Google Books
|
||||
pub async fn try_googlebooks(&self, isbn: &str) -> Result<Option<ExternalMetadata>> {
|
||||
match self.googlebooks.fetch_by_isbn(isbn).await {
|
||||
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))
|
||||
})?;
|
||||
|
||||
Ok(Some(ExternalMetadata {
|
||||
id: Uuid::new_v4(),
|
||||
media_id: crate::model::MediaId(Uuid::nil()), // Will be set by caller
|
||||
source: EnrichmentSourceType::GoogleBooks,
|
||||
external_id: Some(book.id.clone()),
|
||||
metadata_json,
|
||||
confidence: calculate_googlebooks_confidence(&book.volume_info),
|
||||
last_updated: Utc::now(),
|
||||
}))
|
||||
}
|
||||
_ => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// Try to enrich by searching with title and author
|
||||
pub async fn enrich_by_search(
|
||||
&self,
|
||||
title: &str,
|
||||
author: Option<&str>,
|
||||
) -> Result<Option<ExternalMetadata>> {
|
||||
// Try OpenLibrary search first
|
||||
if let Ok(results) = self.openlibrary.search(title, author).await
|
||||
&& let Some(result) = results.first()
|
||||
{
|
||||
let metadata_json = serde_json::to_string(result).map_err(|e| {
|
||||
PinakesError::External(format!("Failed to serialize metadata: {}", e))
|
||||
})?;
|
||||
|
||||
return Ok(Some(ExternalMetadata {
|
||||
id: Uuid::new_v4(),
|
||||
media_id: crate::model::MediaId(Uuid::nil()),
|
||||
source: EnrichmentSourceType::OpenLibrary,
|
||||
external_id: result.key.clone(),
|
||||
metadata_json,
|
||||
confidence: 0.6, // Lower confidence for search results
|
||||
last_updated: Utc::now(),
|
||||
}));
|
||||
}
|
||||
|
||||
// Fall back to Google Books
|
||||
if let Ok(results) = self.googlebooks.search(title, author).await
|
||||
&& let Some(book) = results.first()
|
||||
{
|
||||
let metadata_json = serde_json::to_string(book).map_err(|e| {
|
||||
PinakesError::External(format!("Failed to serialize metadata: {}", e))
|
||||
})?;
|
||||
|
||||
return Ok(Some(ExternalMetadata {
|
||||
id: Uuid::new_v4(),
|
||||
media_id: crate::model::MediaId(Uuid::nil()),
|
||||
source: EnrichmentSourceType::GoogleBooks,
|
||||
external_id: Some(book.id.clone()),
|
||||
metadata_json,
|
||||
confidence: 0.6,
|
||||
last_updated: Utc::now(),
|
||||
}));
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MetadataEnricher for BookEnricher {
|
||||
fn source(&self) -> EnrichmentSourceType {
|
||||
// Returns the preferred source
|
||||
EnrichmentSourceType::OpenLibrary
|
||||
}
|
||||
fn source(&self) -> EnrichmentSourceType {
|
||||
// Returns the preferred source
|
||||
EnrichmentSourceType::OpenLibrary
|
||||
}
|
||||
|
||||
async fn enrich(&self, item: &MediaItem) -> Result<Option<ExternalMetadata>> {
|
||||
// Try ISBN-based enrichment first by checking title/description for ISBN patterns
|
||||
if let Some(ref title) = item.title {
|
||||
if let Some(isbn) = crate::books::extract_isbn_from_text(title) {
|
||||
if let Some(mut metadata) = self.try_openlibrary(&isbn).await? {
|
||||
metadata.media_id = item.id;
|
||||
return Ok(Some(metadata));
|
||||
}
|
||||
if let Some(mut metadata) = self.try_googlebooks(&isbn).await? {
|
||||
metadata.media_id = item.id;
|
||||
return Ok(Some(metadata));
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to title/author search
|
||||
let author = item.artist.as_deref();
|
||||
return self.enrich_by_search(title, author).await;
|
||||
async fn enrich(&self, item: &MediaItem) -> Result<Option<ExternalMetadata>> {
|
||||
// Try ISBN-based enrichment first by checking title/description for ISBN
|
||||
// patterns
|
||||
if let Some(ref title) = item.title {
|
||||
if let Some(isbn) = crate::books::extract_isbn_from_text(title) {
|
||||
if let Some(mut metadata) = self.try_openlibrary(&isbn).await? {
|
||||
metadata.media_id = item.id;
|
||||
return Ok(Some(metadata));
|
||||
}
|
||||
if let Some(mut metadata) = self.try_googlebooks(&isbn).await? {
|
||||
metadata.media_id = item.id;
|
||||
return Ok(Some(metadata));
|
||||
}
|
||||
}
|
||||
|
||||
// No title available
|
||||
Ok(None)
|
||||
// Fall back to title/author search
|
||||
let author = item.artist.as_deref();
|
||||
return self.enrich_by_search(title, author).await;
|
||||
}
|
||||
|
||||
// No title available
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
/// Calculate confidence score for OpenLibrary metadata
|
||||
pub fn calculate_openlibrary_confidence(book: &super::openlibrary::OpenLibraryBook) -> f64 {
|
||||
let mut score: f64 = 0.5; // Base score
|
||||
pub fn calculate_openlibrary_confidence(
|
||||
book: &super::openlibrary::OpenLibraryBook,
|
||||
) -> f64 {
|
||||
let mut score: f64 = 0.5; // Base score
|
||||
|
||||
if book.title.is_some() {
|
||||
score += 0.1;
|
||||
}
|
||||
if !book.authors.is_empty() {
|
||||
score += 0.1;
|
||||
}
|
||||
if !book.publishers.is_empty() {
|
||||
score += 0.05;
|
||||
}
|
||||
if book.publish_date.is_some() {
|
||||
score += 0.05;
|
||||
}
|
||||
if book.description.is_some() {
|
||||
score += 0.1;
|
||||
}
|
||||
if !book.covers.is_empty() {
|
||||
score += 0.1;
|
||||
}
|
||||
if book.title.is_some() {
|
||||
score += 0.1;
|
||||
}
|
||||
if !book.authors.is_empty() {
|
||||
score += 0.1;
|
||||
}
|
||||
if !book.publishers.is_empty() {
|
||||
score += 0.05;
|
||||
}
|
||||
if book.publish_date.is_some() {
|
||||
score += 0.05;
|
||||
}
|
||||
if book.description.is_some() {
|
||||
score += 0.1;
|
||||
}
|
||||
if !book.covers.is_empty() {
|
||||
score += 0.1;
|
||||
}
|
||||
|
||||
score.min(1.0)
|
||||
score.min(1.0)
|
||||
}
|
||||
|
||||
/// Calculate confidence score for Google Books metadata
|
||||
pub fn calculate_googlebooks_confidence(info: &super::googlebooks::VolumeInfo) -> f64 {
|
||||
let mut score: f64 = 0.5; // Base score
|
||||
pub fn calculate_googlebooks_confidence(
|
||||
info: &super::googlebooks::VolumeInfo,
|
||||
) -> f64 {
|
||||
let mut score: f64 = 0.5; // Base score
|
||||
|
||||
if info.title.is_some() {
|
||||
score += 0.1;
|
||||
}
|
||||
if !info.authors.is_empty() {
|
||||
score += 0.1;
|
||||
}
|
||||
if info.publisher.is_some() {
|
||||
score += 0.05;
|
||||
}
|
||||
if info.published_date.is_some() {
|
||||
score += 0.05;
|
||||
}
|
||||
if info.description.is_some() {
|
||||
score += 0.1;
|
||||
}
|
||||
if info.image_links.is_some() {
|
||||
score += 0.1;
|
||||
}
|
||||
if info.title.is_some() {
|
||||
score += 0.1;
|
||||
}
|
||||
if !info.authors.is_empty() {
|
||||
score += 0.1;
|
||||
}
|
||||
if info.publisher.is_some() {
|
||||
score += 0.05;
|
||||
}
|
||||
if info.published_date.is_some() {
|
||||
score += 0.05;
|
||||
}
|
||||
if info.description.is_some() {
|
||||
score += 0.1;
|
||||
}
|
||||
if info.image_links.is_some() {
|
||||
score += 0.1;
|
||||
}
|
||||
|
||||
score.min(1.0)
|
||||
score.min(1.0)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_openlibrary_confidence_calculation() {
|
||||
let book = super::super::openlibrary::OpenLibraryBook {
|
||||
title: Some("Test Book".to_string()),
|
||||
subtitle: None,
|
||||
authors: vec![],
|
||||
publishers: vec![],
|
||||
publish_date: None,
|
||||
number_of_pages: None,
|
||||
subjects: vec![],
|
||||
covers: vec![],
|
||||
isbn_10: vec![],
|
||||
isbn_13: vec![],
|
||||
series: vec![],
|
||||
description: None,
|
||||
languages: vec![],
|
||||
};
|
||||
#[test]
|
||||
fn test_openlibrary_confidence_calculation() {
|
||||
let book = super::super::openlibrary::OpenLibraryBook {
|
||||
title: Some("Test Book".to_string()),
|
||||
subtitle: None,
|
||||
authors: vec![],
|
||||
publishers: vec![],
|
||||
publish_date: None,
|
||||
number_of_pages: None,
|
||||
subjects: vec![],
|
||||
covers: vec![],
|
||||
isbn_10: vec![],
|
||||
isbn_13: vec![],
|
||||
series: vec![],
|
||||
description: None,
|
||||
languages: vec![],
|
||||
};
|
||||
|
||||
let confidence = calculate_openlibrary_confidence(&book);
|
||||
assert_eq!(confidence, 0.6); // 0.5 base + 0.1 for title
|
||||
}
|
||||
let confidence = calculate_openlibrary_confidence(&book);
|
||||
assert_eq!(confidence, 0.6); // 0.5 base + 0.1 for title
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_googlebooks_confidence_calculation() {
|
||||
let info = super::super::googlebooks::VolumeInfo {
|
||||
title: Some("Test Book".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
#[test]
|
||||
fn test_googlebooks_confidence_calculation() {
|
||||
let info = super::super::googlebooks::VolumeInfo {
|
||||
title: Some("Test Book".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let confidence = calculate_googlebooks_confidence(&info);
|
||||
assert_eq!(confidence, 0.6); // 0.5 base + 0.1 for title
|
||||
}
|
||||
let confidence = calculate_googlebooks_confidence(&info);
|
||||
assert_eq!(confidence, 0.6); // 0.5 base + 0.1 for title
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,274 +4,276 @@ use crate::error::{PinakesError, Result};
|
|||
|
||||
/// Google Books API client for book metadata enrichment
|
||||
pub struct GoogleBooksClient {
|
||||
client: reqwest::Client,
|
||||
api_key: Option<String>,
|
||||
client: reqwest::Client,
|
||||
api_key: Option<String>,
|
||||
}
|
||||
|
||||
impl GoogleBooksClient {
|
||||
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,
|
||||
}
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch book metadata by ISBN
|
||||
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
|
||||
);
|
||||
|
||||
if let Some(ref key) = self.api_key {
|
||||
url.push_str(&format!("&key={}", key));
|
||||
}
|
||||
|
||||
/// Fetch book metadata by ISBN
|
||||
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 response = self.client.get(&url).send().await.map_err(|e| {
|
||||
PinakesError::External(format!("Google Books request failed: {}", e))
|
||||
})?;
|
||||
|
||||
if let Some(ref key) = self.api_key {
|
||||
url.push_str(&format!("&key={}", key));
|
||||
}
|
||||
|
||||
let response =
|
||||
self.client.get(&url).send().await.map_err(|e| {
|
||||
PinakesError::External(format!("Google Books request failed: {}", e))
|
||||
})?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(PinakesError::External(format!(
|
||||
"Google Books returned status: {}",
|
||||
response.status()
|
||||
)));
|
||||
}
|
||||
|
||||
let volumes: GoogleBooksResponse = response.json().await.map_err(|e| {
|
||||
PinakesError::External(format!("Failed to parse Google Books response: {}", e))
|
||||
})?;
|
||||
|
||||
Ok(volumes.items)
|
||||
if !response.status().is_success() {
|
||||
return Err(PinakesError::External(format!(
|
||||
"Google Books returned status: {}",
|
||||
response.status()
|
||||
)));
|
||||
}
|
||||
|
||||
/// Search for books by title and author
|
||||
pub async fn search(&self, title: &str, author: Option<&str>) -> Result<Vec<GoogleBook>> {
|
||||
let mut query = format!("intitle:{}", urlencoding::encode(title));
|
||||
let volumes: GoogleBooksResponse = response.json().await.map_err(|e| {
|
||||
PinakesError::External(format!(
|
||||
"Failed to parse Google Books response: {}",
|
||||
e
|
||||
))
|
||||
})?;
|
||||
|
||||
if let Some(author) = author {
|
||||
query.push_str(&format!("+inauthor:{}", urlencoding::encode(author)));
|
||||
}
|
||||
Ok(volumes.items)
|
||||
}
|
||||
|
||||
let mut url = format!(
|
||||
"https://www.googleapis.com/books/v1/volumes?q={}&maxResults=5",
|
||||
query
|
||||
);
|
||||
/// Search for books by title and author
|
||||
pub async fn search(
|
||||
&self,
|
||||
title: &str,
|
||||
author: Option<&str>,
|
||||
) -> Result<Vec<GoogleBook>> {
|
||||
let mut query = format!("intitle:{}", urlencoding::encode(title));
|
||||
|
||||
if let Some(ref key) = self.api_key {
|
||||
url.push_str(&format!("&key={}", key));
|
||||
}
|
||||
|
||||
let response =
|
||||
self.client.get(&url).send().await.map_err(|e| {
|
||||
PinakesError::External(format!("Google Books search failed: {}", e))
|
||||
})?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(PinakesError::External(format!(
|
||||
"Google Books search returned status: {}",
|
||||
response.status()
|
||||
)));
|
||||
}
|
||||
|
||||
let volumes: GoogleBooksResponse = response.json().await.map_err(|e| {
|
||||
PinakesError::External(format!("Failed to parse search results: {}", e))
|
||||
})?;
|
||||
|
||||
Ok(volumes.items)
|
||||
if let Some(author) = author {
|
||||
query.push_str(&format!("+inauthor:{}", urlencoding::encode(author)));
|
||||
}
|
||||
|
||||
/// Download cover image from Google Books
|
||||
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
|
||||
.replace("&zoom=1", "&zoom=2")
|
||||
.replace("&edge=curl", "");
|
||||
let mut url = format!(
|
||||
"https://www.googleapis.com/books/v1/volumes?q={}&maxResults=5",
|
||||
query
|
||||
);
|
||||
|
||||
let response = self
|
||||
.client
|
||||
.get(&high_res_link)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| PinakesError::External(format!("Cover download failed: {}", e)))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(PinakesError::External(format!(
|
||||
"Cover download returned status: {}",
|
||||
response.status()
|
||||
)));
|
||||
}
|
||||
|
||||
response
|
||||
.bytes()
|
||||
.await
|
||||
.map(|b| b.to_vec())
|
||||
.map_err(|e| PinakesError::External(format!("Failed to read cover data: {}", e)))
|
||||
if let Some(ref key) = self.api_key {
|
||||
url.push_str(&format!("&key={}", key));
|
||||
}
|
||||
|
||||
let response = self.client.get(&url).send().await.map_err(|e| {
|
||||
PinakesError::External(format!("Google Books search failed: {}", e))
|
||||
})?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(PinakesError::External(format!(
|
||||
"Google Books search returned status: {}",
|
||||
response.status()
|
||||
)));
|
||||
}
|
||||
|
||||
let volumes: GoogleBooksResponse = response.json().await.map_err(|e| {
|
||||
PinakesError::External(format!("Failed to parse search results: {}", e))
|
||||
})?;
|
||||
|
||||
Ok(volumes.items)
|
||||
}
|
||||
|
||||
/// Download cover image from Google Books
|
||||
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
|
||||
.replace("&zoom=1", "&zoom=2")
|
||||
.replace("&edge=curl", "");
|
||||
|
||||
let response =
|
||||
self.client.get(&high_res_link).send().await.map_err(|e| {
|
||||
PinakesError::External(format!("Cover download failed: {}", e))
|
||||
})?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(PinakesError::External(format!(
|
||||
"Cover download returned status: {}",
|
||||
response.status()
|
||||
)));
|
||||
}
|
||||
|
||||
response.bytes().await.map(|b| b.to_vec()).map_err(|e| {
|
||||
PinakesError::External(format!("Failed to read cover data: {}", e))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct GoogleBooksResponse {
|
||||
#[serde(default)]
|
||||
pub items: Vec<GoogleBook>,
|
||||
#[serde(default)]
|
||||
pub items: Vec<GoogleBook>,
|
||||
|
||||
#[serde(default)]
|
||||
pub total_items: i32,
|
||||
#[serde(default)]
|
||||
pub total_items: i32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct GoogleBook {
|
||||
pub id: String,
|
||||
pub id: String,
|
||||
|
||||
#[serde(default)]
|
||||
pub volume_info: VolumeInfo,
|
||||
#[serde(default)]
|
||||
pub volume_info: VolumeInfo,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct VolumeInfo {
|
||||
#[serde(default)]
|
||||
pub title: Option<String>,
|
||||
#[serde(default)]
|
||||
pub title: Option<String>,
|
||||
|
||||
#[serde(default)]
|
||||
pub subtitle: Option<String>,
|
||||
#[serde(default)]
|
||||
pub subtitle: Option<String>,
|
||||
|
||||
#[serde(default)]
|
||||
pub authors: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub authors: Vec<String>,
|
||||
|
||||
#[serde(default)]
|
||||
pub publisher: Option<String>,
|
||||
#[serde(default)]
|
||||
pub publisher: Option<String>,
|
||||
|
||||
#[serde(default)]
|
||||
pub published_date: Option<String>,
|
||||
#[serde(default)]
|
||||
pub published_date: Option<String>,
|
||||
|
||||
#[serde(default)]
|
||||
pub description: Option<String>,
|
||||
#[serde(default)]
|
||||
pub description: Option<String>,
|
||||
|
||||
#[serde(default)]
|
||||
pub page_count: Option<i32>,
|
||||
#[serde(default)]
|
||||
pub page_count: Option<i32>,
|
||||
|
||||
#[serde(default)]
|
||||
pub categories: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub categories: Vec<String>,
|
||||
|
||||
#[serde(default)]
|
||||
pub average_rating: Option<f64>,
|
||||
#[serde(default)]
|
||||
pub average_rating: Option<f64>,
|
||||
|
||||
#[serde(default)]
|
||||
pub ratings_count: Option<i32>,
|
||||
#[serde(default)]
|
||||
pub ratings_count: Option<i32>,
|
||||
|
||||
#[serde(default)]
|
||||
pub image_links: Option<ImageLinks>,
|
||||
#[serde(default)]
|
||||
pub image_links: Option<ImageLinks>,
|
||||
|
||||
#[serde(default)]
|
||||
pub language: Option<String>,
|
||||
#[serde(default)]
|
||||
pub language: Option<String>,
|
||||
|
||||
#[serde(default)]
|
||||
pub industry_identifiers: Vec<IndustryIdentifier>,
|
||||
#[serde(default)]
|
||||
pub industry_identifiers: Vec<IndustryIdentifier>,
|
||||
|
||||
#[serde(default)]
|
||||
pub main_category: Option<String>,
|
||||
#[serde(default)]
|
||||
pub main_category: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ImageLinks {
|
||||
#[serde(default)]
|
||||
pub small_thumbnail: Option<String>,
|
||||
#[serde(default)]
|
||||
pub small_thumbnail: Option<String>,
|
||||
|
||||
#[serde(default)]
|
||||
pub thumbnail: Option<String>,
|
||||
#[serde(default)]
|
||||
pub thumbnail: Option<String>,
|
||||
|
||||
#[serde(default)]
|
||||
pub small: Option<String>,
|
||||
#[serde(default)]
|
||||
pub small: Option<String>,
|
||||
|
||||
#[serde(default)]
|
||||
pub medium: Option<String>,
|
||||
#[serde(default)]
|
||||
pub medium: Option<String>,
|
||||
|
||||
#[serde(default)]
|
||||
pub large: Option<String>,
|
||||
#[serde(default)]
|
||||
pub large: Option<String>,
|
||||
|
||||
#[serde(default)]
|
||||
pub extra_large: Option<String>,
|
||||
#[serde(default)]
|
||||
pub extra_large: Option<String>,
|
||||
}
|
||||
|
||||
impl ImageLinks {
|
||||
/// Get the best available image link (highest resolution)
|
||||
pub fn best_link(&self) -> Option<&String> {
|
||||
self.extra_large
|
||||
.as_ref()
|
||||
.or(self.large.as_ref())
|
||||
.or(self.medium.as_ref())
|
||||
.or(self.small.as_ref())
|
||||
.or(self.thumbnail.as_ref())
|
||||
.or(self.small_thumbnail.as_ref())
|
||||
}
|
||||
/// Get the best available image link (highest resolution)
|
||||
pub fn best_link(&self) -> Option<&String> {
|
||||
self
|
||||
.extra_large
|
||||
.as_ref()
|
||||
.or(self.large.as_ref())
|
||||
.or(self.medium.as_ref())
|
||||
.or(self.small.as_ref())
|
||||
.or(self.thumbnail.as_ref())
|
||||
.or(self.small_thumbnail.as_ref())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct IndustryIdentifier {
|
||||
#[serde(rename = "type")]
|
||||
pub identifier_type: String,
|
||||
#[serde(rename = "type")]
|
||||
pub identifier_type: String,
|
||||
|
||||
pub identifier: String,
|
||||
pub identifier: String,
|
||||
}
|
||||
|
||||
impl IndustryIdentifier {
|
||||
/// Check if this is an ISBN-13
|
||||
pub fn is_isbn13(&self) -> bool {
|
||||
self.identifier_type == "ISBN_13"
|
||||
}
|
||||
/// Check if this is an ISBN-13
|
||||
pub fn is_isbn13(&self) -> bool {
|
||||
self.identifier_type == "ISBN_13"
|
||||
}
|
||||
|
||||
/// Check if this is an ISBN-10
|
||||
pub fn is_isbn10(&self) -> bool {
|
||||
self.identifier_type == "ISBN_10"
|
||||
}
|
||||
/// Check if this is an ISBN-10
|
||||
pub fn is_isbn10(&self) -> bool {
|
||||
self.identifier_type == "ISBN_10"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_googlebooks_client_creation() {
|
||||
let client = GoogleBooksClient::new(None);
|
||||
assert!(client.api_key.is_none());
|
||||
#[test]
|
||||
fn test_googlebooks_client_creation() {
|
||||
let client = GoogleBooksClient::new(None);
|
||||
assert!(client.api_key.is_none());
|
||||
|
||||
let client_with_key = GoogleBooksClient::new(Some("test-key".to_string()));
|
||||
assert_eq!(client_with_key.api_key, Some("test-key".to_string()));
|
||||
}
|
||||
let client_with_key = GoogleBooksClient::new(Some("test-key".to_string()));
|
||||
assert_eq!(client_with_key.api_key, Some("test-key".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_image_links_best_link() {
|
||||
let links = ImageLinks {
|
||||
small_thumbnail: Some("small.jpg".to_string()),
|
||||
thumbnail: Some("thumb.jpg".to_string()),
|
||||
small: None,
|
||||
medium: Some("medium.jpg".to_string()),
|
||||
large: Some("large.jpg".to_string()),
|
||||
extra_large: None,
|
||||
};
|
||||
#[test]
|
||||
fn test_image_links_best_link() {
|
||||
let links = ImageLinks {
|
||||
small_thumbnail: Some("small.jpg".to_string()),
|
||||
thumbnail: Some("thumb.jpg".to_string()),
|
||||
small: None,
|
||||
medium: Some("medium.jpg".to_string()),
|
||||
large: Some("large.jpg".to_string()),
|
||||
extra_large: None,
|
||||
};
|
||||
|
||||
assert_eq!(links.best_link(), Some(&"large.jpg".to_string()));
|
||||
}
|
||||
assert_eq!(links.best_link(), Some(&"large.jpg".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_industry_identifier_type_checks() {
|
||||
let isbn13 = IndustryIdentifier {
|
||||
identifier_type: "ISBN_13".to_string(),
|
||||
identifier: "9780123456789".to_string(),
|
||||
};
|
||||
assert!(isbn13.is_isbn13());
|
||||
assert!(!isbn13.is_isbn10());
|
||||
#[test]
|
||||
fn test_industry_identifier_type_checks() {
|
||||
let isbn13 = IndustryIdentifier {
|
||||
identifier_type: "ISBN_13".to_string(),
|
||||
identifier: "9780123456789".to_string(),
|
||||
};
|
||||
assert!(isbn13.is_isbn13());
|
||||
assert!(!isbn13.is_isbn10());
|
||||
|
||||
let isbn10 = IndustryIdentifier {
|
||||
identifier_type: "ISBN_10".to_string(),
|
||||
identifier: "0123456789".to_string(),
|
||||
};
|
||||
assert!(!isbn10.is_isbn13());
|
||||
assert!(isbn10.is_isbn10());
|
||||
}
|
||||
let isbn10 = IndustryIdentifier {
|
||||
identifier_type: "ISBN_10".to_string(),
|
||||
identifier: "0123456789".to_string(),
|
||||
};
|
||||
assert!(!isbn10.is_isbn13());
|
||||
assert!(isbn10.is_isbn10());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,105 +5,110 @@ use std::time::Duration;
|
|||
use chrono::Utc;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::{PinakesError, Result};
|
||||
use crate::model::MediaItem;
|
||||
|
||||
use super::{EnrichmentSourceType, ExternalMetadata, MetadataEnricher};
|
||||
use crate::{
|
||||
error::{PinakesError, Result},
|
||||
model::MediaItem,
|
||||
};
|
||||
|
||||
pub struct LastFmEnricher {
|
||||
client: reqwest::Client,
|
||||
api_key: String,
|
||||
base_url: String,
|
||||
client: reqwest::Client,
|
||||
api_key: String,
|
||||
base_url: String,
|
||||
}
|
||||
|
||||
impl LastFmEnricher {
|
||||
pub fn new(api_key: String) -> Self {
|
||||
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"),
|
||||
api_key,
|
||||
base_url: "https://ws.audioscrobbler.com/2.0".to_string(),
|
||||
}
|
||||
pub fn new(api_key: String) -> Self {
|
||||
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"),
|
||||
api_key,
|
||||
base_url: "https://ws.audioscrobbler.com/2.0".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MetadataEnricher for LastFmEnricher {
|
||||
fn source(&self) -> EnrichmentSourceType {
|
||||
EnrichmentSourceType::LastFm
|
||||
fn source(&self) -> EnrichmentSourceType {
|
||||
EnrichmentSourceType::LastFm
|
||||
}
|
||||
|
||||
async fn enrich(&self, item: &MediaItem) -> Result<Option<ExternalMetadata>> {
|
||||
let artist = match &item.artist {
|
||||
Some(a) if !a.is_empty() => a,
|
||||
_ => return Ok(None),
|
||||
};
|
||||
|
||||
let title = match &item.title {
|
||||
Some(t) if !t.is_empty() => t,
|
||||
_ => return Ok(None),
|
||||
};
|
||||
|
||||
let url = format!("{}/", self.base_url);
|
||||
|
||||
let resp = self
|
||||
.client
|
||||
.get(&url)
|
||||
.query(&[
|
||||
("method", "track.getInfo"),
|
||||
("api_key", self.api_key.as_str()),
|
||||
("artist", artist.as_str()),
|
||||
("track", title.as_str()),
|
||||
("format", "json"),
|
||||
])
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| {
|
||||
PinakesError::MetadataExtraction(format!("Last.fm request failed: {e}"))
|
||||
})?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
async fn enrich(&self, item: &MediaItem) -> Result<Option<ExternalMetadata>> {
|
||||
let artist = match &item.artist {
|
||||
Some(a) if !a.is_empty() => a,
|
||||
_ => return Ok(None),
|
||||
};
|
||||
let body = resp.text().await.map_err(|e| {
|
||||
PinakesError::MetadataExtraction(format!(
|
||||
"Last.fm response read failed: {e}"
|
||||
))
|
||||
})?;
|
||||
|
||||
let title = match &item.title {
|
||||
Some(t) if !t.is_empty() => t,
|
||||
_ => return Ok(None),
|
||||
};
|
||||
let json: serde_json::Value = serde_json::from_str(&body).map_err(|e| {
|
||||
PinakesError::MetadataExtraction(format!(
|
||||
"Last.fm JSON parse failed: {e}"
|
||||
))
|
||||
})?;
|
||||
|
||||
let url = format!("{}/", self.base_url);
|
||||
|
||||
let resp = self
|
||||
.client
|
||||
.get(&url)
|
||||
.query(&[
|
||||
("method", "track.getInfo"),
|
||||
("api_key", self.api_key.as_str()),
|
||||
("artist", artist.as_str()),
|
||||
("track", title.as_str()),
|
||||
("format", "json"),
|
||||
])
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| {
|
||||
PinakesError::MetadataExtraction(format!("Last.fm request failed: {e}"))
|
||||
})?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let body = resp.text().await.map_err(|e| {
|
||||
PinakesError::MetadataExtraction(format!("Last.fm response read failed: {e}"))
|
||||
})?;
|
||||
|
||||
let json: serde_json::Value = serde_json::from_str(&body).map_err(|e| {
|
||||
PinakesError::MetadataExtraction(format!("Last.fm JSON parse failed: {e}"))
|
||||
})?;
|
||||
|
||||
// Check for error response
|
||||
if json.get("error").is_some() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let track = match json.get("track") {
|
||||
Some(t) => t,
|
||||
None => return Ok(None),
|
||||
};
|
||||
|
||||
let mbid = track.get("mbid").and_then(|m| m.as_str()).map(String::from);
|
||||
let listeners = track
|
||||
.get("listeners")
|
||||
.and_then(|l| l.as_str())
|
||||
.and_then(|l| l.parse::<f64>().ok())
|
||||
.unwrap_or(0.0);
|
||||
// Normalize listeners to confidence (arbitrary scale)
|
||||
let confidence = (listeners / 1_000_000.0).min(1.0);
|
||||
|
||||
Ok(Some(ExternalMetadata {
|
||||
id: Uuid::now_v7(),
|
||||
media_id: item.id,
|
||||
source: EnrichmentSourceType::LastFm,
|
||||
external_id: mbid,
|
||||
metadata_json: body,
|
||||
confidence,
|
||||
last_updated: Utc::now(),
|
||||
}))
|
||||
// Check for error response
|
||||
if json.get("error").is_some() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let track = match json.get("track") {
|
||||
Some(t) => t,
|
||||
None => return Ok(None),
|
||||
};
|
||||
|
||||
let mbid = track.get("mbid").and_then(|m| m.as_str()).map(String::from);
|
||||
let listeners = track
|
||||
.get("listeners")
|
||||
.and_then(|l| l.as_str())
|
||||
.and_then(|l| l.parse::<f64>().ok())
|
||||
.unwrap_or(0.0);
|
||||
// Normalize listeners to confidence (arbitrary scale)
|
||||
let confidence = (listeners / 1_000_000.0).min(1.0);
|
||||
|
||||
Ok(Some(ExternalMetadata {
|
||||
id: Uuid::now_v7(),
|
||||
media_id: item.id,
|
||||
source: EnrichmentSourceType::LastFm,
|
||||
external_id: mbid,
|
||||
metadata_json: body,
|
||||
confidence,
|
||||
last_updated: Utc::now(),
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,67 +11,69 @@ use chrono::{DateTime, Utc};
|
|||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::Result;
|
||||
use crate::model::{MediaId, MediaItem};
|
||||
use crate::{
|
||||
error::Result,
|
||||
model::{MediaId, MediaItem},
|
||||
};
|
||||
|
||||
/// Externally-sourced metadata for a media item.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ExternalMetadata {
|
||||
pub id: Uuid,
|
||||
pub media_id: MediaId,
|
||||
pub source: EnrichmentSourceType,
|
||||
pub external_id: Option<String>,
|
||||
pub metadata_json: String,
|
||||
pub confidence: f64,
|
||||
pub last_updated: DateTime<Utc>,
|
||||
pub id: Uuid,
|
||||
pub media_id: MediaId,
|
||||
pub source: EnrichmentSourceType,
|
||||
pub external_id: Option<String>,
|
||||
pub metadata_json: String,
|
||||
pub confidence: f64,
|
||||
pub last_updated: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// Supported enrichment data sources.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum EnrichmentSourceType {
|
||||
#[serde(rename = "musicbrainz")]
|
||||
MusicBrainz,
|
||||
#[serde(rename = "tmdb")]
|
||||
Tmdb,
|
||||
#[serde(rename = "lastfm")]
|
||||
LastFm,
|
||||
#[serde(rename = "openlibrary")]
|
||||
OpenLibrary,
|
||||
#[serde(rename = "googlebooks")]
|
||||
GoogleBooks,
|
||||
#[serde(rename = "musicbrainz")]
|
||||
MusicBrainz,
|
||||
#[serde(rename = "tmdb")]
|
||||
Tmdb,
|
||||
#[serde(rename = "lastfm")]
|
||||
LastFm,
|
||||
#[serde(rename = "openlibrary")]
|
||||
OpenLibrary,
|
||||
#[serde(rename = "googlebooks")]
|
||||
GoogleBooks,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for EnrichmentSourceType {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
let s = match self {
|
||||
Self::MusicBrainz => "musicbrainz",
|
||||
Self::Tmdb => "tmdb",
|
||||
Self::LastFm => "lastfm",
|
||||
Self::OpenLibrary => "openlibrary",
|
||||
Self::GoogleBooks => "googlebooks",
|
||||
};
|
||||
write!(f, "{s}")
|
||||
}
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
let s = match self {
|
||||
Self::MusicBrainz => "musicbrainz",
|
||||
Self::Tmdb => "tmdb",
|
||||
Self::LastFm => "lastfm",
|
||||
Self::OpenLibrary => "openlibrary",
|
||||
Self::GoogleBooks => "googlebooks",
|
||||
};
|
||||
write!(f, "{s}")
|
||||
}
|
||||
}
|
||||
|
||||
impl std::str::FromStr for EnrichmentSourceType {
|
||||
type Err = String;
|
||||
type Err = String;
|
||||
|
||||
fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
|
||||
match s {
|
||||
"musicbrainz" => Ok(Self::MusicBrainz),
|
||||
"tmdb" => Ok(Self::Tmdb),
|
||||
"lastfm" => Ok(Self::LastFm),
|
||||
"openlibrary" => Ok(Self::OpenLibrary),
|
||||
"googlebooks" => Ok(Self::GoogleBooks),
|
||||
_ => Err(format!("unknown enrichment source: {s}")),
|
||||
}
|
||||
fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
|
||||
match s {
|
||||
"musicbrainz" => Ok(Self::MusicBrainz),
|
||||
"tmdb" => Ok(Self::Tmdb),
|
||||
"lastfm" => Ok(Self::LastFm),
|
||||
"openlibrary" => Ok(Self::OpenLibrary),
|
||||
"googlebooks" => Ok(Self::GoogleBooks),
|
||||
_ => Err(format!("unknown enrichment source: {s}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Trait for metadata enrichment providers.
|
||||
#[async_trait::async_trait]
|
||||
pub trait MetadataEnricher: Send + Sync {
|
||||
fn source(&self) -> EnrichmentSourceType;
|
||||
async fn enrich(&self, item: &MediaItem) -> Result<Option<ExternalMetadata>>;
|
||||
fn source(&self) -> EnrichmentSourceType;
|
||||
async fn enrich(&self, item: &MediaItem) -> Result<Option<ExternalMetadata>>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,130 +5,137 @@ use std::time::Duration;
|
|||
use chrono::Utc;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::{PinakesError, Result};
|
||||
use crate::model::MediaItem;
|
||||
|
||||
use super::{EnrichmentSourceType, ExternalMetadata, MetadataEnricher};
|
||||
use crate::{
|
||||
error::{PinakesError, Result},
|
||||
model::MediaItem,
|
||||
};
|
||||
|
||||
pub struct MusicBrainzEnricher {
|
||||
client: reqwest::Client,
|
||||
base_url: String,
|
||||
client: reqwest::Client,
|
||||
base_url: String,
|
||||
}
|
||||
|
||||
impl Default for MusicBrainzEnricher {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl MusicBrainzEnricher {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
client: reqwest::Client::builder()
|
||||
.user_agent("Pinakes/0.1 (https://github.com/notashelf/pinakes)")
|
||||
.timeout(Duration::from_secs(10))
|
||||
.connect_timeout(Duration::from_secs(5))
|
||||
.build()
|
||||
.expect("failed to build HTTP client with configured timeouts"),
|
||||
base_url: "https://musicbrainz.org/ws/2".to_string(),
|
||||
}
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
client: reqwest::Client::builder()
|
||||
.user_agent("Pinakes/0.1 (https://github.com/notashelf/pinakes)")
|
||||
.timeout(Duration::from_secs(10))
|
||||
.connect_timeout(Duration::from_secs(5))
|
||||
.build()
|
||||
.expect("failed to build HTTP client with configured timeouts"),
|
||||
base_url: "https://musicbrainz.org/ws/2".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn escape_lucene_query(s: &str) -> String {
|
||||
let special_chars = [
|
||||
'+', '-', '&', '|', '!', '(', ')', '{', '}', '[', ']', '^', '"', '~', '*', '?', ':', '\\',
|
||||
'/',
|
||||
];
|
||||
let mut escaped = String::with_capacity(s.len() * 2);
|
||||
for c in s.chars() {
|
||||
if special_chars.contains(&c) {
|
||||
escaped.push('\\');
|
||||
}
|
||||
escaped.push(c);
|
||||
let special_chars = [
|
||||
'+', '-', '&', '|', '!', '(', ')', '{', '}', '[', ']', '^', '"', '~', '*',
|
||||
'?', ':', '\\', '/',
|
||||
];
|
||||
let mut escaped = String::with_capacity(s.len() * 2);
|
||||
for c in s.chars() {
|
||||
if special_chars.contains(&c) {
|
||||
escaped.push('\\');
|
||||
}
|
||||
escaped
|
||||
escaped.push(c);
|
||||
}
|
||||
escaped
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MetadataEnricher for MusicBrainzEnricher {
|
||||
fn source(&self) -> EnrichmentSourceType {
|
||||
EnrichmentSourceType::MusicBrainz
|
||||
fn source(&self) -> EnrichmentSourceType {
|
||||
EnrichmentSourceType::MusicBrainz
|
||||
}
|
||||
|
||||
async fn enrich(&self, item: &MediaItem) -> Result<Option<ExternalMetadata>> {
|
||||
let title = match &item.title {
|
||||
Some(t) if !t.is_empty() => t,
|
||||
_ => return Ok(None),
|
||||
};
|
||||
|
||||
let mut query = format!("recording:{}", escape_lucene_query(title));
|
||||
if let Some(ref artist) = item.artist {
|
||||
query.push_str(&format!(" AND artist:{}", escape_lucene_query(artist)));
|
||||
}
|
||||
|
||||
async fn enrich(&self, item: &MediaItem) -> Result<Option<ExternalMetadata>> {
|
||||
let title = match &item.title {
|
||||
Some(t) if !t.is_empty() => t,
|
||||
_ => return Ok(None),
|
||||
};
|
||||
let url = format!("{}/recording/", self.base_url);
|
||||
|
||||
let mut query = format!("recording:{}", escape_lucene_query(title));
|
||||
if let Some(ref artist) = item.artist {
|
||||
query.push_str(&format!(" AND artist:{}", escape_lucene_query(artist)));
|
||||
}
|
||||
let resp = self
|
||||
.client
|
||||
.get(&url)
|
||||
.query(&[
|
||||
("query", &query),
|
||||
("fmt", &"json".to_string()),
|
||||
("limit", &"1".to_string()),
|
||||
])
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| {
|
||||
PinakesError::MetadataExtraction(format!(
|
||||
"MusicBrainz request failed: {e}"
|
||||
))
|
||||
})?;
|
||||
|
||||
let url = format!("{}/recording/", self.base_url);
|
||||
|
||||
let resp = self
|
||||
.client
|
||||
.get(&url)
|
||||
.query(&[
|
||||
("query", &query),
|
||||
("fmt", &"json".to_string()),
|
||||
("limit", &"1".to_string()),
|
||||
])
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| {
|
||||
PinakesError::MetadataExtraction(format!("MusicBrainz request failed: {e}"))
|
||||
})?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
if status == reqwest::StatusCode::TOO_MANY_REQUESTS
|
||||
|| status == reqwest::StatusCode::SERVICE_UNAVAILABLE
|
||||
{
|
||||
return Err(PinakesError::MetadataExtraction(format!(
|
||||
"MusicBrainz rate limited (HTTP {})",
|
||||
status.as_u16()
|
||||
)));
|
||||
}
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let body = resp.text().await.map_err(|e| {
|
||||
PinakesError::MetadataExtraction(format!("MusicBrainz response read failed: {e}"))
|
||||
})?;
|
||||
|
||||
// Parse to check if we got results
|
||||
let json: serde_json::Value = serde_json::from_str(&body).map_err(|e| {
|
||||
PinakesError::MetadataExtraction(format!("MusicBrainz JSON parse failed: {e}"))
|
||||
})?;
|
||||
|
||||
let recordings = json.get("recordings").and_then(|r| r.as_array());
|
||||
if recordings.is_none_or(|r| r.is_empty()) {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let recording = &recordings.unwrap()[0];
|
||||
let external_id = recording
|
||||
.get("id")
|
||||
.and_then(|id| id.as_str())
|
||||
.map(String::from);
|
||||
let score = recording
|
||||
.get("score")
|
||||
.and_then(|s| s.as_f64())
|
||||
.unwrap_or(0.0)
|
||||
/ 100.0;
|
||||
|
||||
Ok(Some(ExternalMetadata {
|
||||
id: Uuid::now_v7(),
|
||||
media_id: item.id,
|
||||
source: EnrichmentSourceType::MusicBrainz,
|
||||
external_id,
|
||||
metadata_json: body,
|
||||
confidence: score,
|
||||
last_updated: Utc::now(),
|
||||
}))
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
if status == reqwest::StatusCode::TOO_MANY_REQUESTS
|
||||
|| status == reqwest::StatusCode::SERVICE_UNAVAILABLE
|
||||
{
|
||||
return Err(PinakesError::MetadataExtraction(format!(
|
||||
"MusicBrainz rate limited (HTTP {})",
|
||||
status.as_u16()
|
||||
)));
|
||||
}
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let body = resp.text().await.map_err(|e| {
|
||||
PinakesError::MetadataExtraction(format!(
|
||||
"MusicBrainz response read failed: {e}"
|
||||
))
|
||||
})?;
|
||||
|
||||
// Parse to check if we got results
|
||||
let json: serde_json::Value = serde_json::from_str(&body).map_err(|e| {
|
||||
PinakesError::MetadataExtraction(format!(
|
||||
"MusicBrainz JSON parse failed: {e}"
|
||||
))
|
||||
})?;
|
||||
|
||||
let recordings = json.get("recordings").and_then(|r| r.as_array());
|
||||
if recordings.is_none_or(|r| r.is_empty()) {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let recording = &recordings.unwrap()[0];
|
||||
let external_id = recording
|
||||
.get("id")
|
||||
.and_then(|id| id.as_str())
|
||||
.map(String::from);
|
||||
let score = recording
|
||||
.get("score")
|
||||
.and_then(|s| s.as_f64())
|
||||
.unwrap_or(0.0)
|
||||
/ 100.0;
|
||||
|
||||
Ok(Some(ExternalMetadata {
|
||||
id: Uuid::now_v7(),
|
||||
media_id: item.id,
|
||||
source: EnrichmentSourceType::MusicBrainz,
|
||||
external_id,
|
||||
metadata_json: body,
|
||||
confidence: score,
|
||||
last_updated: Utc::now(),
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,285 +4,284 @@ use crate::error::{PinakesError, Result};
|
|||
|
||||
/// OpenLibrary API client for book metadata enrichment
|
||||
pub struct OpenLibraryClient {
|
||||
client: reqwest::Client,
|
||||
base_url: String,
|
||||
client: reqwest::Client,
|
||||
base_url: String,
|
||||
}
|
||||
|
||||
impl Default for OpenLibraryClient {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl OpenLibraryClient {
|
||||
pub fn new() -> 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"),
|
||||
base_url: "https://openlibrary.org".to_string(),
|
||||
}
|
||||
pub fn new() -> 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"),
|
||||
base_url: "https://openlibrary.org".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch book metadata by ISBN
|
||||
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))
|
||||
})?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(PinakesError::External(format!(
|
||||
"OpenLibrary returned status: {}",
|
||||
response.status()
|
||||
)));
|
||||
}
|
||||
|
||||
/// Fetch book metadata by ISBN
|
||||
pub async fn fetch_by_isbn(&self, isbn: &str) -> Result<OpenLibraryBook> {
|
||||
let url = format!("{}/isbn/{}.json", self.base_url, isbn);
|
||||
response.json::<OpenLibraryBook>().await.map_err(|e| {
|
||||
PinakesError::External(format!(
|
||||
"Failed to parse OpenLibrary response: {}",
|
||||
e
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
let response =
|
||||
self.client.get(&url).send().await.map_err(|e| {
|
||||
PinakesError::External(format!("OpenLibrary request failed: {}", e))
|
||||
})?;
|
||||
/// Search for books by title and author
|
||||
pub async fn search(
|
||||
&self,
|
||||
title: &str,
|
||||
author: Option<&str>,
|
||||
) -> Result<Vec<OpenLibrarySearchResult>> {
|
||||
let mut url = format!(
|
||||
"{}/search.json?title={}",
|
||||
self.base_url,
|
||||
urlencoding::encode(title)
|
||||
);
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(PinakesError::External(format!(
|
||||
"OpenLibrary returned status: {}",
|
||||
response.status()
|
||||
)));
|
||||
}
|
||||
|
||||
response.json::<OpenLibraryBook>().await.map_err(|e| {
|
||||
PinakesError::External(format!("Failed to parse OpenLibrary response: {}", e))
|
||||
})
|
||||
if let Some(author) = author {
|
||||
url.push_str(&format!("&author={}", urlencoding::encode(author)));
|
||||
}
|
||||
|
||||
/// Search for books by title and author
|
||||
pub async fn search(
|
||||
&self,
|
||||
title: &str,
|
||||
author: Option<&str>,
|
||||
) -> Result<Vec<OpenLibrarySearchResult>> {
|
||||
let mut url = format!(
|
||||
"{}/search.json?title={}",
|
||||
self.base_url,
|
||||
urlencoding::encode(title)
|
||||
);
|
||||
url.push_str("&limit=5");
|
||||
|
||||
if let Some(author) = author {
|
||||
url.push_str(&format!("&author={}", urlencoding::encode(author)));
|
||||
}
|
||||
let response = self.client.get(&url).send().await.map_err(|e| {
|
||||
PinakesError::External(format!("OpenLibrary search failed: {}", e))
|
||||
})?;
|
||||
|
||||
url.push_str("&limit=5");
|
||||
|
||||
let response = self
|
||||
.client
|
||||
.get(&url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| PinakesError::External(format!("OpenLibrary search failed: {}", e)))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(PinakesError::External(format!(
|
||||
"OpenLibrary search returned status: {}",
|
||||
response.status()
|
||||
)));
|
||||
}
|
||||
|
||||
let search_response: OpenLibrarySearchResponse = response.json().await.map_err(|e| {
|
||||
PinakesError::External(format!("Failed to parse search results: {}", e))
|
||||
})?;
|
||||
|
||||
Ok(search_response.docs)
|
||||
if !response.status().is_success() {
|
||||
return Err(PinakesError::External(format!(
|
||||
"OpenLibrary search returned status: {}",
|
||||
response.status()
|
||||
)));
|
||||
}
|
||||
|
||||
/// Fetch cover image by cover ID
|
||||
pub async fn fetch_cover(&self, cover_id: i64, size: CoverSize) -> Result<Vec<u8>> {
|
||||
let size_str = match size {
|
||||
CoverSize::Small => "S",
|
||||
CoverSize::Medium => "M",
|
||||
CoverSize::Large => "L",
|
||||
};
|
||||
let search_response: OpenLibrarySearchResponse =
|
||||
response.json().await.map_err(|e| {
|
||||
PinakesError::External(format!("Failed to parse search results: {}", e))
|
||||
})?;
|
||||
|
||||
let url = format!(
|
||||
"https://covers.openlibrary.org/b/id/{}-{}.jpg",
|
||||
cover_id, size_str
|
||||
);
|
||||
Ok(search_response.docs)
|
||||
}
|
||||
|
||||
let response = self
|
||||
.client
|
||||
.get(&url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| PinakesError::External(format!("Cover download failed: {}", e)))?;
|
||||
/// Fetch cover image by cover ID
|
||||
pub async fn fetch_cover(
|
||||
&self,
|
||||
cover_id: i64,
|
||||
size: CoverSize,
|
||||
) -> Result<Vec<u8>> {
|
||||
let size_str = match size {
|
||||
CoverSize::Small => "S",
|
||||
CoverSize::Medium => "M",
|
||||
CoverSize::Large => "L",
|
||||
};
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(PinakesError::External(format!(
|
||||
"Cover download returned status: {}",
|
||||
response.status()
|
||||
)));
|
||||
}
|
||||
let url = format!(
|
||||
"https://covers.openlibrary.org/b/id/{}-{}.jpg",
|
||||
cover_id, size_str
|
||||
);
|
||||
|
||||
response
|
||||
.bytes()
|
||||
.await
|
||||
.map(|b| b.to_vec())
|
||||
.map_err(|e| PinakesError::External(format!("Failed to read cover data: {}", e)))
|
||||
let response = self.client.get(&url).send().await.map_err(|e| {
|
||||
PinakesError::External(format!("Cover download failed: {}", e))
|
||||
})?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(PinakesError::External(format!(
|
||||
"Cover download returned status: {}",
|
||||
response.status()
|
||||
)));
|
||||
}
|
||||
|
||||
/// Fetch cover by ISBN
|
||||
pub async fn fetch_cover_by_isbn(&self, isbn: &str, size: CoverSize) -> Result<Vec<u8>> {
|
||||
let size_str = match size {
|
||||
CoverSize::Small => "S",
|
||||
CoverSize::Medium => "M",
|
||||
CoverSize::Large => "L",
|
||||
};
|
||||
response.bytes().await.map(|b| b.to_vec()).map_err(|e| {
|
||||
PinakesError::External(format!("Failed to read cover data: {}", e))
|
||||
})
|
||||
}
|
||||
|
||||
let url = format!(
|
||||
"https://covers.openlibrary.org/b/isbn/{}-{}.jpg",
|
||||
isbn, size_str
|
||||
);
|
||||
/// Fetch cover by ISBN
|
||||
pub async fn fetch_cover_by_isbn(
|
||||
&self,
|
||||
isbn: &str,
|
||||
size: CoverSize,
|
||||
) -> Result<Vec<u8>> {
|
||||
let size_str = match size {
|
||||
CoverSize::Small => "S",
|
||||
CoverSize::Medium => "M",
|
||||
CoverSize::Large => "L",
|
||||
};
|
||||
|
||||
let response = self
|
||||
.client
|
||||
.get(&url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| PinakesError::External(format!("Cover download failed: {}", e)))?;
|
||||
let url = format!(
|
||||
"https://covers.openlibrary.org/b/isbn/{}-{}.jpg",
|
||||
isbn, size_str
|
||||
);
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(PinakesError::External(format!(
|
||||
"Cover download returned status: {}",
|
||||
response.status()
|
||||
)));
|
||||
}
|
||||
let response = self.client.get(&url).send().await.map_err(|e| {
|
||||
PinakesError::External(format!("Cover download failed: {}", e))
|
||||
})?;
|
||||
|
||||
response
|
||||
.bytes()
|
||||
.await
|
||||
.map(|b| b.to_vec())
|
||||
.map_err(|e| PinakesError::External(format!("Failed to read cover data: {}", e)))
|
||||
if !response.status().is_success() {
|
||||
return Err(PinakesError::External(format!(
|
||||
"Cover download returned status: {}",
|
||||
response.status()
|
||||
)));
|
||||
}
|
||||
|
||||
response.bytes().await.map(|b| b.to_vec()).map_err(|e| {
|
||||
PinakesError::External(format!("Failed to read cover data: {}", e))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum CoverSize {
|
||||
Small, // 256x256
|
||||
Medium, // 600x800
|
||||
Large, // Original
|
||||
Small, // 256x256
|
||||
Medium, // 600x800
|
||||
Large, // Original
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct OpenLibraryBook {
|
||||
#[serde(default)]
|
||||
pub title: Option<String>,
|
||||
#[serde(default)]
|
||||
pub title: Option<String>,
|
||||
|
||||
#[serde(default)]
|
||||
pub subtitle: Option<String>,
|
||||
#[serde(default)]
|
||||
pub subtitle: Option<String>,
|
||||
|
||||
#[serde(default)]
|
||||
pub authors: Vec<AuthorRef>,
|
||||
#[serde(default)]
|
||||
pub authors: Vec<AuthorRef>,
|
||||
|
||||
#[serde(default)]
|
||||
pub publishers: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub publishers: Vec<String>,
|
||||
|
||||
#[serde(default)]
|
||||
pub publish_date: Option<String>,
|
||||
#[serde(default)]
|
||||
pub publish_date: Option<String>,
|
||||
|
||||
#[serde(default)]
|
||||
pub number_of_pages: Option<i32>,
|
||||
#[serde(default)]
|
||||
pub number_of_pages: Option<i32>,
|
||||
|
||||
#[serde(default)]
|
||||
pub subjects: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub subjects: Vec<String>,
|
||||
|
||||
#[serde(default)]
|
||||
pub covers: Vec<i64>,
|
||||
#[serde(default)]
|
||||
pub covers: Vec<i64>,
|
||||
|
||||
#[serde(default)]
|
||||
pub isbn_10: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub isbn_10: Vec<String>,
|
||||
|
||||
#[serde(default)]
|
||||
pub isbn_13: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub isbn_13: Vec<String>,
|
||||
|
||||
#[serde(default)]
|
||||
pub series: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub series: Vec<String>,
|
||||
|
||||
#[serde(default)]
|
||||
pub description: Option<StringOrObject>,
|
||||
#[serde(default)]
|
||||
pub description: Option<StringOrObject>,
|
||||
|
||||
#[serde(default)]
|
||||
pub languages: Vec<LanguageRef>,
|
||||
#[serde(default)]
|
||||
pub languages: Vec<LanguageRef>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AuthorRef {
|
||||
pub key: String,
|
||||
pub key: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct LanguageRef {
|
||||
pub key: String,
|
||||
pub key: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
pub enum StringOrObject {
|
||||
String(String),
|
||||
Object { value: String },
|
||||
String(String),
|
||||
Object { value: String },
|
||||
}
|
||||
|
||||
impl StringOrObject {
|
||||
pub fn as_str(&self) -> &str {
|
||||
match self {
|
||||
Self::String(s) => s,
|
||||
Self::Object { value } => value,
|
||||
}
|
||||
pub fn as_str(&self) -> &str {
|
||||
match self {
|
||||
Self::String(s) => s,
|
||||
Self::Object { value } => value,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct OpenLibrarySearchResponse {
|
||||
#[serde(default)]
|
||||
pub docs: Vec<OpenLibrarySearchResult>,
|
||||
#[serde(default)]
|
||||
pub docs: Vec<OpenLibrarySearchResult>,
|
||||
|
||||
#[serde(default)]
|
||||
pub num_found: i32,
|
||||
#[serde(default)]
|
||||
pub num_found: i32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct OpenLibrarySearchResult {
|
||||
#[serde(default)]
|
||||
pub key: Option<String>,
|
||||
#[serde(default)]
|
||||
pub key: Option<String>,
|
||||
|
||||
#[serde(default)]
|
||||
pub title: Option<String>,
|
||||
#[serde(default)]
|
||||
pub title: Option<String>,
|
||||
|
||||
#[serde(default)]
|
||||
pub author_name: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub author_name: Vec<String>,
|
||||
|
||||
#[serde(default)]
|
||||
pub first_publish_year: Option<i32>,
|
||||
#[serde(default)]
|
||||
pub first_publish_year: Option<i32>,
|
||||
|
||||
#[serde(default)]
|
||||
pub publisher: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub publisher: Vec<String>,
|
||||
|
||||
#[serde(default)]
|
||||
pub isbn: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub isbn: Vec<String>,
|
||||
|
||||
#[serde(default)]
|
||||
pub cover_i: Option<i64>,
|
||||
#[serde(default)]
|
||||
pub cover_i: Option<i64>,
|
||||
|
||||
#[serde(default)]
|
||||
pub subject: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub subject: Vec<String>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_openlibrary_client_creation() {
|
||||
let client = OpenLibraryClient::new();
|
||||
assert_eq!(client.base_url, "https://openlibrary.org");
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn test_openlibrary_client_creation() {
|
||||
let client = OpenLibraryClient::new();
|
||||
assert_eq!(client.base_url, "https://openlibrary.org");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_string_or_object_parsing() {
|
||||
let string_desc: StringOrObject = serde_json::from_str(r#""Simple description""#).unwrap();
|
||||
assert_eq!(string_desc.as_str(), "Simple description");
|
||||
#[test]
|
||||
fn test_string_or_object_parsing() {
|
||||
let string_desc: StringOrObject =
|
||||
serde_json::from_str(r#""Simple description""#).unwrap();
|
||||
assert_eq!(string_desc.as_str(), "Simple description");
|
||||
|
||||
let object_desc: StringOrObject =
|
||||
serde_json::from_str(r#"{"value": "Object description"}"#).unwrap();
|
||||
assert_eq!(object_desc.as_str(), "Object description");
|
||||
}
|
||||
let object_desc: StringOrObject =
|
||||
serde_json::from_str(r#"{"value": "Object description"}"#).unwrap();
|
||||
assert_eq!(object_desc.as_str(), "Object description");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,105 +5,110 @@ use std::time::Duration;
|
|||
use chrono::Utc;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::{PinakesError, Result};
|
||||
use crate::model::MediaItem;
|
||||
|
||||
use super::{EnrichmentSourceType, ExternalMetadata, MetadataEnricher};
|
||||
use crate::{
|
||||
error::{PinakesError, Result},
|
||||
model::MediaItem,
|
||||
};
|
||||
|
||||
pub struct TmdbEnricher {
|
||||
client: reqwest::Client,
|
||||
api_key: String,
|
||||
base_url: String,
|
||||
client: reqwest::Client,
|
||||
api_key: String,
|
||||
base_url: String,
|
||||
}
|
||||
|
||||
impl TmdbEnricher {
|
||||
pub fn new(api_key: String) -> Self {
|
||||
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"),
|
||||
api_key,
|
||||
base_url: "https://api.themoviedb.org/3".to_string(),
|
||||
}
|
||||
pub fn new(api_key: String) -> Self {
|
||||
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"),
|
||||
api_key,
|
||||
base_url: "https://api.themoviedb.org/3".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MetadataEnricher for TmdbEnricher {
|
||||
fn source(&self) -> EnrichmentSourceType {
|
||||
EnrichmentSourceType::Tmdb
|
||||
fn source(&self) -> EnrichmentSourceType {
|
||||
EnrichmentSourceType::Tmdb
|
||||
}
|
||||
|
||||
async fn enrich(&self, item: &MediaItem) -> Result<Option<ExternalMetadata>> {
|
||||
let title = match &item.title {
|
||||
Some(t) if !t.is_empty() => t,
|
||||
_ => return Ok(None),
|
||||
};
|
||||
|
||||
let url = format!("{}/search/movie", self.base_url);
|
||||
|
||||
let resp = self
|
||||
.client
|
||||
.get(&url)
|
||||
.query(&[
|
||||
("api_key", &self.api_key),
|
||||
("query", &title.to_string()),
|
||||
("page", &"1".to_string()),
|
||||
])
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| {
|
||||
PinakesError::MetadataExtraction(format!("TMDB request failed: {e}"))
|
||||
})?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
if status == reqwest::StatusCode::UNAUTHORIZED {
|
||||
return Err(PinakesError::MetadataExtraction(
|
||||
"TMDB API key is invalid (401)".into(),
|
||||
));
|
||||
}
|
||||
if status == reqwest::StatusCode::TOO_MANY_REQUESTS {
|
||||
tracing::warn!("TMDB rate limit exceeded (429)");
|
||||
return Ok(None);
|
||||
}
|
||||
tracing::debug!(status = %status, "TMDB search returned non-success status");
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
async fn enrich(&self, item: &MediaItem) -> Result<Option<ExternalMetadata>> {
|
||||
let title = match &item.title {
|
||||
Some(t) if !t.is_empty() => t,
|
||||
_ => return Ok(None),
|
||||
};
|
||||
let body = resp.text().await.map_err(|e| {
|
||||
PinakesError::MetadataExtraction(format!(
|
||||
"TMDB response read failed: {e}"
|
||||
))
|
||||
})?;
|
||||
|
||||
let url = format!("{}/search/movie", self.base_url);
|
||||
let json: serde_json::Value = serde_json::from_str(&body).map_err(|e| {
|
||||
PinakesError::MetadataExtraction(format!("TMDB JSON parse failed: {e}"))
|
||||
})?;
|
||||
|
||||
let resp = self
|
||||
.client
|
||||
.get(&url)
|
||||
.query(&[
|
||||
("api_key", &self.api_key),
|
||||
("query", &title.to_string()),
|
||||
("page", &"1".to_string()),
|
||||
])
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| PinakesError::MetadataExtraction(format!("TMDB request failed: {e}")))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
if status == reqwest::StatusCode::UNAUTHORIZED {
|
||||
return Err(PinakesError::MetadataExtraction(
|
||||
"TMDB API key is invalid (401)".into(),
|
||||
));
|
||||
}
|
||||
if status == reqwest::StatusCode::TOO_MANY_REQUESTS {
|
||||
tracing::warn!("TMDB rate limit exceeded (429)");
|
||||
return Ok(None);
|
||||
}
|
||||
tracing::debug!(status = %status, "TMDB search returned non-success status");
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let body = resp.text().await.map_err(|e| {
|
||||
PinakesError::MetadataExtraction(format!("TMDB response read failed: {e}"))
|
||||
})?;
|
||||
|
||||
let json: serde_json::Value = serde_json::from_str(&body).map_err(|e| {
|
||||
PinakesError::MetadataExtraction(format!("TMDB JSON parse failed: {e}"))
|
||||
})?;
|
||||
|
||||
let results = json.get("results").and_then(|r| r.as_array());
|
||||
if results.is_none_or(|r| r.is_empty()) {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let movie = &results.unwrap()[0];
|
||||
let external_id = match movie.get("id").and_then(|id| id.as_i64()) {
|
||||
Some(id) => id.to_string(),
|
||||
None => return Ok(None),
|
||||
};
|
||||
let popularity = movie
|
||||
.get("popularity")
|
||||
.and_then(|p| p.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);
|
||||
|
||||
Ok(Some(ExternalMetadata {
|
||||
id: Uuid::now_v7(),
|
||||
media_id: item.id,
|
||||
source: EnrichmentSourceType::Tmdb,
|
||||
external_id: Some(external_id),
|
||||
metadata_json: body,
|
||||
confidence,
|
||||
last_updated: Utc::now(),
|
||||
}))
|
||||
let results = json.get("results").and_then(|r| r.as_array());
|
||||
if results.is_none_or(|r| r.is_empty()) {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let movie = &results.unwrap()[0];
|
||||
let external_id = match movie.get("id").and_then(|id| id.as_i64()) {
|
||||
Some(id) => id.to_string(),
|
||||
None => return Ok(None),
|
||||
};
|
||||
let popularity = movie
|
||||
.get("popularity")
|
||||
.and_then(|p| p.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);
|
||||
|
||||
Ok(Some(ExternalMetadata {
|
||||
id: Uuid::now_v7(),
|
||||
media_id: item.id,
|
||||
source: EnrichmentSourceType::Tmdb,
|
||||
external_id: Some(external_id),
|
||||
metadata_json: body,
|
||||
confidence,
|
||||
last_updated: Utc::now(),
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue