Files
newznab-tmux/app/Services/GoogleBooksService.php
T
2026-08-13 15:53:31 +02:00

249 lines
7.8 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Services;
use App\Exceptions\BookProviderException;
use App\Support\BookIsbn;
use Carbon\Carbon;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\GuzzleException;
use Illuminate\Support\Facades\Cache;
use JsonException;
class GoogleBooksService
{
protected const API_URL = 'https://www.googleapis.com/books/v1/volumes';
protected Client $client;
protected ?string $apiKey;
protected int $maxResults = 5;
public function __construct(?Client $client = null, ?string $apiKey = null)
{
$this->apiKey = trim($apiKey ?? (string) config('nntmux_api.google_books_api_key', '')) ?: null;
$this->client = $client ?? new Client([
'timeout' => 15,
'connect_timeout' => 10,
'http_errors' => false,
'headers' => [
'Accept' => 'application/json',
'User-Agent' => 'NNTmux/1.0',
],
]);
}
public function hasApiKey(): bool
{
return $this->apiKey !== null;
}
public function isConfigured(): bool
{
return true;
}
/**
* @return array<string, mixed>|null
*/
public function findByIsbn(string $isbn): ?array
{
$books = $this->searchBooks('', null, null, $isbn);
return $books[0] ?? null;
}
/**
* @return array<string, mixed>|null
*/
public function searchBook(string $query, ?string $title = null, ?string $author = null): ?array
{
$books = $this->searchBooks($query, $title, $author);
return $books[0] ?? null;
}
/**
* @return array<int, array<string, mixed>>
*/
public function searchBooks(string $query, ?string $title = null, ?string $author = null, ?string $isbn = null): array
{
$query = trim($query);
$title = $title !== null ? trim($title) : null;
$author = $author !== null ? trim($author) : null;
$isbn = BookIsbn::normalize($isbn);
$searchQuery = $this->buildSearchQuery($query, $title, $author, $isbn);
if ($searchQuery === '') {
return [];
}
$cacheKey = 'google_books_search_'.md5($searchQuery.'_'.$this->maxResults.'_'.$this->apiKey);
$cached = Cache::get($cacheKey);
if (is_array($cached)) {
return $cached;
}
$response = $this->request([
'q' => $searchQuery,
'maxResults' => $this->maxResults,
'printType' => 'books',
]);
if (! is_array($response['items'] ?? null)) {
return [];
}
$books = [];
foreach ($response['items'] as $item) {
if (is_array($item)) {
$books[] = $this->normalizeBookResult($item);
}
}
Cache::put($cacheKey, $books, now()->addHours(12));
return $books;
}
/**
* @param array<string, mixed> $query
* @return array<string, mixed>|null
*/
protected function request(array $query): ?array
{
try {
if ($this->apiKey !== null) {
$query['key'] = $this->apiKey;
}
$response = $this->client->get(self::API_URL, [
'http_errors' => false,
'query' => $query,
]);
if ($response->getStatusCode() !== 200) {
throw new BookProviderException(
'google_books',
'Google Books API request failed.',
$response->getStatusCode(),
);
}
$decoded = json_decode($response->getBody()->getContents(), true, 512, JSON_THROW_ON_ERROR);
if (! is_array($decoded)) {
throw new BookProviderException('google_books', 'Google Books returned an invalid response payload.', 200);
}
return $decoded;
} catch (BookProviderException $exception) {
throw $exception;
} catch (JsonException $exception) {
throw new BookProviderException(
'google_books',
'Google Books returned malformed JSON.',
200,
null,
$exception,
);
} catch (GuzzleException $e) {
throw new BookProviderException(
'google_books',
'Google Books API request error: '.$e->getMessage(),
null,
null,
$e,
);
}
}
private function buildSearchQuery(string $query, ?string $title, ?string $author, ?string $isbn): string
{
if ($isbn !== null && $isbn !== '') {
return 'isbn:'.$isbn;
}
$parts = [];
if ($title !== null && $title !== '') {
$parts[] = 'intitle:'.$title;
}
if ($author !== null && $author !== '') {
$parts[] = 'inauthor:'.$author;
}
if ($parts !== []) {
return implode(' ', $parts);
}
return $query;
}
/**
* @param array<string, mixed> $item
* @return array<string, mixed>
*/
private function normalizeBookResult(array $item): array
{
$volumeInfo = is_array($item['volumeInfo'] ?? null) ? $item['volumeInfo'] : [];
$industryIdentifiers = is_array($volumeInfo['industryIdentifiers'] ?? null) ? $volumeInfo['industryIdentifiers'] : [];
$isbn13 = '';
$isbn10 = '';
foreach ($industryIdentifiers as $identifier) {
if (! is_array($identifier)) {
continue;
}
$type = (string) ($identifier['type'] ?? '');
$value = BookIsbn::normalize((string) ($identifier['identifier'] ?? ''));
if ($type === 'ISBN_13' && $value !== null && strlen($value) === 13) {
$isbn13 = $value;
}
if ($type === 'ISBN_10' && $value !== null && strlen($value) === 10) {
$isbn10 = $value;
}
}
$identifier = $isbn13 !== '' ? $isbn13 : ($isbn10 !== '' ? $isbn10 : (string) ($item['id'] ?? md5(json_encode($item) ?: '')));
$authors = is_array($volumeInfo['authors'] ?? null)
? array_values(array_filter(array_map('strval', $volumeInfo['authors'])))
: [];
$categories = is_array($volumeInfo['categories'] ?? null)
? array_values(array_filter(array_map('strval', $volumeInfo['categories'])))
: [];
$imageLinks = is_array($volumeInfo['imageLinks'] ?? null) ? $volumeInfo['imageLinks'] : [];
$coverUrl = (string) ($imageLinks['thumbnail'] ?? $imageLinks['smallThumbnail'] ?? '');
return [
'title' => (string) ($volumeInfo['title'] ?? ''),
'author' => implode(', ', $authors),
'asin' => 'googlebooks:'.$identifier,
'isbn' => $isbn13 !== '' ? $isbn13 : null,
'ean' => $isbn10 !== '' ? $isbn10 : null,
'url' => (string) ($volumeInfo['infoLink'] ?? ''),
'salesrank' => '',
'publisher' => (string) ($volumeInfo['publisher'] ?? ''),
'publishdate' => $this->normalizePublishDate($volumeInfo['publishedDate'] ?? null),
'pages' => isset($volumeInfo['pageCount']) && is_numeric($volumeInfo['pageCount']) ? (string) ((int) $volumeInfo['pageCount']) : '',
'overview' => strip_tags((string) ($volumeInfo['description'] ?? '')),
'genre' => implode(', ', $categories),
'coverurl' => $coverUrl,
'cover' => $coverUrl !== '' ? 1 : 0,
];
}
private function normalizePublishDate(mixed $date): ?string
{
if (! is_string($date) || trim($date) === '') {
return null;
}
try {
return Carbon::parse($date)->format('Y-m-d');
} catch (\Exception) {
return null;
}
}
}