bookcli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
bookcli/config.py ADDED
@@ -0,0 +1,72 @@
1
+ """Configuration management for BookCLI."""
2
+
3
+ import json
4
+ import logging
5
+ from pathlib import Path
6
+ from typing import Any, Dict
7
+ from pydantic import BaseModel, Field
8
+
9
+ from bookcli.settings import CONFIG_PATH, DEFAULT_CONFIG
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ class ProvidersConfig(BaseModel):
15
+ """Configuration for individual book search providers."""
16
+ google_books: bool = True
17
+ openlibrary: bool = True
18
+ gutenberg: bool = True
19
+ internet_archive: bool = True
20
+
21
+
22
+ class AppConfig(BaseModel):
23
+ """Application-wide configuration settings."""
24
+ download_dir: str
25
+ cache_ttl_seconds: int = Field(default=86400, ge=0)
26
+ timeout_seconds: int = Field(default=15, ge=1)
27
+ theme: str = "dark"
28
+ providers: ProvidersConfig = Field(default_factory=ProvidersConfig)
29
+
30
+
31
+ def load_config() -> AppConfig:
32
+ """Loads configuration from config.json, merging with default config."""
33
+ config_dict = DEFAULT_CONFIG.copy()
34
+
35
+ if CONFIG_PATH.exists():
36
+ try:
37
+ with open(CONFIG_PATH, "r", encoding="utf-8") as f:
38
+ user_config = json.load(f)
39
+ # Merge user config with defaults (shallow dict update for top-level keys,
40
+ # custom merge for nested providers)
41
+ for key, value in user_config.items():
42
+ if key == "providers" and isinstance(value, dict):
43
+ # Merge providers dict
44
+ for p_key, p_val in value.items():
45
+ if p_key in config_dict["providers"]:
46
+ config_dict["providers"][p_key] = bool(p_val)
47
+ else:
48
+ config_dict[key] = value
49
+ except Exception as e:
50
+ logger.warning("Failed to load config file: %s. Using default settings.", e)
51
+
52
+ # Instantiate and validate through Pydantic
53
+ try:
54
+ return AppConfig(**config_dict)
55
+ except Exception as e:
56
+ logger.warning("Validation of configuration failed: %s. Using default settings.", e)
57
+ # Force default settings if validation fails
58
+ return AppConfig(**DEFAULT_CONFIG)
59
+
60
+
61
+ def save_config(config: AppConfig) -> None:
62
+ """Saves configuration to the config.json file."""
63
+ try:
64
+ # Create directory if it doesn't exist
65
+ CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
66
+ with open(CONFIG_PATH, "w", encoding="utf-8") as f:
67
+ # We use dump_model to turn it into dict, then standard json dump
68
+ json.dump(config.model_dump(), f, indent=4)
69
+ logger.info("Configuration saved successfully to %s", CONFIG_PATH)
70
+ except Exception as e:
71
+ logger.error("Failed to save configuration: %s", e)
72
+ raise e
@@ -0,0 +1,32 @@
1
+ """Database connection and query management using aiosqlite."""
2
+
3
+ import os
4
+ import aiosqlite
5
+ import logging
6
+ from typing import AsyncGenerator
7
+ from contextlib import asynccontextmanager
8
+ from bookcli.database.migrations import run_migrations
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ class DatabaseManager:
14
+ """Manages SQLite database connections and tables initialization."""
15
+
16
+ def __init__(self, db_path: str):
17
+ self.db_path = db_path
18
+ # Ensure parent directory exists
19
+ parent_dir = os.path.dirname(db_path)
20
+ if parent_dir:
21
+ os.makedirs(parent_dir, exist_ok=True)
22
+
23
+ @asynccontextmanager
24
+ async def connection(self) -> AsyncGenerator[aiosqlite.Connection, None]:
25
+ """Context manager for obtaining a database connection."""
26
+ async with aiosqlite.connect(self.db_path) as conn:
27
+ conn.row_factory = aiosqlite.Row
28
+ yield conn
29
+
30
+ async def initialize(self) -> None:
31
+ """Runs migrations to initialize database schema."""
32
+ await run_migrations(self.db_path)
@@ -0,0 +1,94 @@
1
+ """Database migrations for BookCLI."""
2
+
3
+ import logging
4
+ import aiosqlite
5
+
6
+ logger = logging.getLogger(__name__)
7
+
8
+ MIGRATIONS = [
9
+ # Migration 1: Create search_history table
10
+ """
11
+ CREATE TABLE IF NOT EXISTS search_history (
12
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
13
+ query TEXT NOT NULL,
14
+ timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
15
+ results_count INTEGER NOT NULL
16
+ );
17
+ """,
18
+ # Migration 2: Create cached_books table
19
+ """
20
+ CREATE TABLE IF NOT EXISTS cached_books (
21
+ id TEXT PRIMARY KEY,
22
+ provider TEXT NOT NULL,
23
+ json_data TEXT NOT NULL,
24
+ cached_at DATETIME DEFAULT CURRENT_TIMESTAMP
25
+ );
26
+ """,
27
+ # Migration 3: Create downloads table
28
+ """
29
+ CREATE TABLE IF NOT EXISTS downloads (
30
+ id TEXT PRIMARY KEY,
31
+ title TEXT NOT NULL,
32
+ file_path TEXT NOT NULL,
33
+ status TEXT NOT NULL,
34
+ downloaded_at DATETIME DEFAULT CURRENT_TIMESTAMP,
35
+ checksum TEXT
36
+ );
37
+ """,
38
+ # Migration 4: Create favorites table
39
+ """
40
+ CREATE TABLE IF NOT EXISTS favorites (
41
+ book_id TEXT PRIMARY KEY,
42
+ title TEXT NOT NULL,
43
+ added_at DATETIME DEFAULT CURRENT_TIMESTAMP
44
+ );
45
+ """,
46
+ # Migration 5: Create search_results_session table for short indexing support
47
+ """
48
+ CREATE TABLE IF NOT EXISTS search_results_session (
49
+ result_index INTEGER PRIMARY KEY,
50
+ book_id TEXT NOT NULL
51
+ );
52
+ """
53
+ ]
54
+
55
+
56
+ async def run_migrations(db_path: str) -> None:
57
+ """Run all database migrations using aiosqlite."""
58
+ logger.info("Running database migrations on %s...", db_path)
59
+ async with aiosqlite.connect(db_path) as db:
60
+ # Create a migration tracking table if it doesn't exist
61
+ await db.execute(
62
+ """
63
+ CREATE TABLE IF NOT EXISTS schema_version (
64
+ version INTEGER PRIMARY KEY
65
+ );
66
+ """
67
+ )
68
+ await db.commit()
69
+
70
+ # Get current version
71
+ async with db.execute("SELECT version FROM schema_version LIMIT 1") as cursor:
72
+ row = await cursor.fetchone()
73
+ current_version = row[0] if row else 0
74
+
75
+ logger.debug("Current DB schema version: %s", current_version)
76
+
77
+ # Run any pending migrations
78
+ for version, migration_sql in enumerate(MIGRATIONS, start=1):
79
+ if version > current_version:
80
+ logger.info("Applying migration version %s...", version)
81
+ try:
82
+ await db.execute(migration_sql)
83
+ if current_version == 0 and version == 1:
84
+ await db.execute("INSERT INTO schema_version (version) VALUES (?)", (version,))
85
+ else:
86
+ await db.execute("UPDATE schema_version SET version = ?", (version,))
87
+ await db.commit()
88
+ current_version = version
89
+ except Exception as e:
90
+ await db.rollback()
91
+ logger.error("Failed to apply migration %s: %s", version, e)
92
+ raise e
93
+
94
+ logger.info("Database migrations completed successfully.")
bookcli/downloader.py ADDED
@@ -0,0 +1,130 @@
1
+ """Downloader service for fetching files asynchronously with resume capability and Rich progress."""
2
+
3
+ import os
4
+ import hashlib
5
+ import time
6
+ import logging
7
+ from typing import Optional
8
+ import httpx
9
+ from rich.progress import (
10
+ Progress,
11
+ SpinnerColumn,
12
+ BarColumn,
13
+ TextColumn,
14
+ DownloadColumn,
15
+ TransferSpeedColumn,
16
+ TimeRemainingColumn
17
+ )
18
+
19
+ from bookcli.exceptions import DownloadError
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+
24
+ def calculate_sha256(file_path: str) -> str:
25
+ """Calculates the SHA256 checksum of a file."""
26
+ sha256 = hashlib.sha256()
27
+ with open(file_path, "rb") as f:
28
+ while chunk := f.read(8192):
29
+ sha256.update(chunk)
30
+ return sha256.hexdigest()
31
+
32
+
33
+ async def download_file(
34
+ url: str,
35
+ dest_path: str,
36
+ timeout_seconds: int = 15
37
+ ) -> str:
38
+ """Downloads a file asynchronously from a URL with resume support, showing a Rich progress bar.
39
+
40
+ Returns:
41
+ The SHA256 checksum of the completed file.
42
+ """
43
+ part_path = dest_path + ".part"
44
+ initial_bytes = 0
45
+
46
+ if os.path.exists(part_path):
47
+ initial_bytes = os.path.getsize(part_path)
48
+ logger.info("Found partial download of size %d bytes. Attempting to resume...", initial_bytes)
49
+
50
+ headers = {
51
+ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
52
+ }
53
+ if initial_bytes > 0:
54
+ headers["Range"] = f"bytes={initial_bytes}-"
55
+
56
+ try:
57
+ async with httpx.AsyncClient(timeout=timeout_seconds, follow_redirects=True) as client:
58
+ async with client.stream("GET", url, headers=headers) as response:
59
+ if response.status_code == 416:
60
+ # Range not satisfiable, might mean the file was already fully downloaded
61
+ # Let's delete part file and restart or just raise
62
+ logger.warning("Range not satisfiable (416). Restarting download.")
63
+ if os.path.exists(part_path):
64
+ os.remove(part_path)
65
+ initial_bytes = 0
66
+ # Retry without Range
67
+ return await download_file(url, dest_path, timeout_seconds)
68
+
69
+ if response.status_code not in (200, 206):
70
+ raise DownloadError(
71
+ f"Server returned HTTP status code {response.status_code} for download."
72
+ )
73
+
74
+ is_resume = response.status_code == 206
75
+ total_bytes = int(response.headers.get("content-length", 0))
76
+
77
+ if is_resume:
78
+ total_bytes += initial_bytes
79
+ write_mode = "ab"
80
+ else:
81
+ initial_bytes = 0
82
+ write_mode = "wb"
83
+
84
+ # Ensure destination directory exists
85
+ os.makedirs(os.path.dirname(dest_path), exist_ok=True)
86
+
87
+ filename = os.path.basename(dest_path)
88
+
89
+ # Setup Rich Progress bar
90
+ with Progress(
91
+ SpinnerColumn(),
92
+ TextColumn("[progress.description]{task.description}"),
93
+ BarColumn(),
94
+ DownloadColumn(),
95
+ TransferSpeedColumn(),
96
+ TimeRemainingColumn(),
97
+ transient=True # Clear the progress bar after completion
98
+ ) as progress:
99
+
100
+ task_desc = f"Downloading {filename}"
101
+ task_id = progress.add_task(
102
+ task_desc,
103
+ total=total_bytes,
104
+ completed=initial_bytes
105
+ )
106
+
107
+ with open(part_path, write_mode) as f:
108
+ async for chunk in response.aiter_bytes(chunk_size=8192):
109
+ f.write(chunk)
110
+ progress.update(task_id, advance=len(chunk))
111
+
112
+ # Rename part file to final file
113
+ if os.path.exists(dest_path):
114
+ os.remove(dest_path)
115
+ os.rename(part_path, dest_path)
116
+
117
+ # Calculate Checksum
118
+ checksum = calculate_sha256(dest_path)
119
+ logger.info("Download completed successfully. SHA256: %s", checksum)
120
+ return checksum
121
+
122
+ except httpx.RequestError as e:
123
+ logger.error("Network error during download: %s", e)
124
+ raise DownloadError(f"Network error while downloading: {e}")
125
+ except (IOError, OSError) as e:
126
+ logger.error("Disk I/O error during download: %s", e)
127
+ raise DownloadError(f"Failed to write downloaded file: {e}")
128
+ except Exception as e:
129
+ logger.error("Unexpected error during download: %s", e)
130
+ raise DownloadError(f"Download failed: {e}")
bookcli/exceptions.py ADDED
@@ -0,0 +1,34 @@
1
+ """Custom exceptions for BookCLI."""
2
+
3
+
4
+ class BookCLIError(Exception):
5
+ """Base exception for BookCLI."""
6
+
7
+ def __init__(self, message: str):
8
+ super().__init__(message)
9
+ self.message = message
10
+
11
+
12
+ class ProviderError(BookCLIError):
13
+ """Raised when an API request to a provider fails or times out."""
14
+ pass
15
+
16
+
17
+ class DownloadError(BookCLIError):
18
+ """Raised when downloading a file fails, is interrupted, or checksum fails."""
19
+ pass
20
+
21
+
22
+ class ConfigError(BookCLIError):
23
+ """Raised when configuration is invalid or cannot be saved."""
24
+ pass
25
+
26
+
27
+ class DatabaseError(BookCLIError):
28
+ """Raised when a database query or migration fails."""
29
+ pass
30
+
31
+
32
+ class BookNotFoundError(BookCLIError):
33
+ """Raised when a specific book or file cannot be found in search results, database, or disk."""
34
+ pass
bookcli/models.py ADDED
@@ -0,0 +1,23 @@
1
+ """Data models for BookCLI."""
2
+
3
+ from typing import List, Optional
4
+ from pydantic import BaseModel, Field
5
+
6
+
7
+ class BookMetadata(BaseModel):
8
+ """Pydantic model representing normalized book metadata across all providers."""
9
+ id: str = Field(description="Unique identifier, e.g., 'gutenberg:1234' or 'google:abcde'")
10
+ title: str = Field(description="The primary title of the book")
11
+ subtitle: Optional[str] = Field(default=None, description="Subtitle if available")
12
+ authors: List[str] = Field(default_factory=list, description="List of author names")
13
+ description: Optional[str] = Field(default=None, description="Synopsis or description of the book")
14
+ language: Optional[str] = Field(default=None, description="Language code (e.g., 'en', 'fr')")
15
+ publisher: Optional[str] = Field(default=None, description="Publisher name")
16
+ published_year: Optional[int] = Field(default=None, description="Year of publication")
17
+ pages: Optional[int] = Field(default=None, description="Number of pages")
18
+ isbn: Optional[str] = Field(default=None, description="International Standard Book Number")
19
+ cover_url: Optional[str] = Field(default=None, description="URL of the cover page image")
20
+ download_url: Optional[str] = Field(default=None, description="Direct legal download link if available")
21
+ source: str = Field(description="Source provider name (gutenberg, google_books, etc.)")
22
+ download_availability: bool = Field(default=False, description="Flag indicating if a legal download file is available")
23
+ file_format: Optional[str] = Field(default=None, description="Format of the downloadable file (epub, pdf, txt, etc.)")
bookcli/opener.py ADDED
@@ -0,0 +1,32 @@
1
+ """OS default application opener service for BookCLI."""
2
+
3
+ import os
4
+ import sys
5
+ import subprocess
6
+ import logging
7
+ from bookcli.exceptions import BookCLIError
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+
12
+ def open_file_in_default_app(file_path: str) -> None:
13
+ """Opens a file using the operating system's default application."""
14
+ if not os.path.exists(file_path):
15
+ raise BookCLIError(f"File not found: {file_path}")
16
+
17
+ abs_path = os.path.abspath(file_path)
18
+ logger.info("Opening file: %s", abs_path)
19
+
20
+ try:
21
+ if sys.platform.startswith("win32"):
22
+ # Windows
23
+ os.startfile(abs_path)
24
+ elif sys.platform.startswith("darwin"):
25
+ # macOS
26
+ subprocess.run(["open", abs_path], check=True)
27
+ else:
28
+ # Linux / Unix
29
+ subprocess.run(["xdg-open", abs_path], check=True)
30
+ except Exception as e:
31
+ logger.error("Failed to open file %s: %s", abs_path, e)
32
+ raise BookCLIError(f"Failed to open file in default application: {e}")
@@ -0,0 +1 @@
1
+ """Book providers for BookCLI."""
@@ -0,0 +1,61 @@
1
+ """Base class and interface for book search and metadata providers."""
2
+
3
+ from abc import ABC, abstractmethod
4
+ from typing import List, Optional
5
+ from bookcli.models import BookMetadata
6
+
7
+
8
+ class BaseProvider(ABC):
9
+ """Abstract base class representing a book metadata and search provider."""
10
+
11
+ def __init__(self, name: str, timeout_seconds: int = 15):
12
+ self.name = name
13
+ self.timeout_seconds = timeout_seconds
14
+
15
+ @abstractmethod
16
+ async def search(
17
+ self,
18
+ query: str,
19
+ author: Optional[str] = None,
20
+ isbn: Optional[str] = None,
21
+ publisher: Optional[str] = None,
22
+ subject: Optional[str] = None
23
+ ) -> List[BookMetadata]:
24
+ """Searches the provider for books matching the search criteria.
25
+
26
+ Args:
27
+ query: Free-text search query.
28
+ author: Optional author name to filter/search.
29
+ isbn: Optional ISBN to search.
30
+ publisher: Optional publisher to search.
31
+ subject: Optional subject to search.
32
+
33
+ Returns:
34
+ A list of validated BookMetadata items.
35
+ """
36
+ pass
37
+
38
+ @abstractmethod
39
+ async def get_book(self, book_id: str) -> Optional[BookMetadata]:
40
+ """Fetches detailed metadata for a specific book by ID.
41
+
42
+ Args:
43
+ book_id: Unique identifier for the book.
44
+
45
+ Returns:
46
+ BookMetadata if found, else None.
47
+ """
48
+ pass
49
+
50
+ @abstractmethod
51
+ async def download(self, book: BookMetadata, dest_path: str) -> None:
52
+ """Downloads the book content to the destination path.
53
+
54
+ Args:
55
+ book: The BookMetadata object containing download information.
56
+ dest_path: Location where the file will be saved.
57
+
58
+ Raises:
59
+ DownloadError: If download fails or is not available.
60
+ """
61
+ pass
@@ -0,0 +1,202 @@
1
+ """Google Books API provider implementation."""
2
+
3
+ import logging
4
+ from typing import List, Optional
5
+ import httpx
6
+
7
+ from bookcli.exceptions import ProviderError, DownloadError
8
+ from bookcli.models import BookMetadata
9
+ from bookcli.providers.base import BaseProvider
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ class GoogleBooksProvider(BaseProvider):
15
+ """Google Books API provider."""
16
+
17
+ def __init__(self, timeout_seconds: int = 15):
18
+ super().__init__(name="google_books", timeout_seconds=timeout_seconds)
19
+ self.base_url = "https://www.googleapis.com/books/v1/volumes"
20
+
21
+ def _build_query(
22
+ self,
23
+ query: str,
24
+ author: Optional[str] = None,
25
+ isbn: Optional[str] = None,
26
+ publisher: Optional[str] = None,
27
+ subject: Optional[str] = None
28
+ ) -> str:
29
+ """Constructs the Google Books 'q' parameter query string."""
30
+ parts = []
31
+ if query.strip():
32
+ parts.append(query.strip())
33
+ if author:
34
+ parts.append(f"inauthor:{author}")
35
+ if isbn:
36
+ parts.append(f"isbn:{isbn}")
37
+ if publisher:
38
+ parts.append(f"inpublisher:{publisher}")
39
+ if subject:
40
+ parts.append(f"subject:{subject}")
41
+ return " ".join(parts)
42
+
43
+ def _parse_published_year(self, date_str: Optional[str]) -> Optional[int]:
44
+ """Extracts the year from a Google Books publishedDate (e.g. '2016-11' or '2016')."""
45
+ if not date_str:
46
+ return None
47
+ try:
48
+ year_part = date_str.split("-")[0]
49
+ return int(year_part)
50
+ except (ValueError, IndexError):
51
+ return None
52
+
53
+ def _parse_isbn(self, identifiers: Optional[List[dict]]) -> Optional[str]:
54
+ """Finds the ISBN_13 or ISBN_10 from the industry identifiers list."""
55
+ if not identifiers:
56
+ return None
57
+ # Prefer ISBN_13
58
+ for ident in identifiers:
59
+ if ident.get("type") == "ISBN_13":
60
+ return ident.get("identifier")
61
+ # Fallback to ISBN_10
62
+ for ident in identifiers:
63
+ if ident.get("type") == "ISBN_10":
64
+ return ident.get("identifier")
65
+ # Fallback to any identifier
66
+ if identifiers:
67
+ return identifiers[0].get("identifier")
68
+ return None
69
+
70
+ def _map_volume_to_metadata(self, item: dict) -> BookMetadata:
71
+ """Maps Google Books API volume resource to BookMetadata model."""
72
+ volume_info = item.get("volumeInfo", {})
73
+ access_info = item.get("accessInfo", {})
74
+
75
+ book_id = f"google:{item.get('id')}"
76
+ title = volume_info.get("title", "Unknown Title")
77
+ subtitle = volume_info.get("subtitle")
78
+ authors = volume_info.get("authors", [])
79
+ description = volume_info.get("description")
80
+ language = volume_info.get("language")
81
+ publisher = volume_info.get("publisher")
82
+ published_year = self._parse_published_year(volume_info.get("publishedDate"))
83
+ pages = volume_info.get("pageCount")
84
+ isbn = self._parse_isbn(volume_info.get("industryIdentifiers"))
85
+
86
+ image_links = volume_info.get("imageLinks", {})
87
+ # Prefer thumbnail, then smallThumbnail
88
+ cover_url = image_links.get("thumbnail") or image_links.get("smallThumbnail")
89
+
90
+ # Determine download availability and format
91
+ # Check if direct download is allowed legally (public domain or explicitly free)
92
+ download_available = False
93
+ download_url = None
94
+ file_format = None
95
+
96
+ epub_info = access_info.get("epub", {})
97
+ pdf_info = access_info.get("pdf", {})
98
+
99
+ # If epub download link is available
100
+ if epub_info.get("isAvailable") and epub_info.get("downloadLink"):
101
+ download_available = True
102
+ download_url = epub_info.get("downloadLink")
103
+ file_format = "epub"
104
+ # Otherwise, check pdf
105
+ elif pdf_info.get("isAvailable") and pdf_info.get("downloadLink"):
106
+ download_available = True
107
+ download_url = pdf_info.get("downloadLink")
108
+ file_format = "pdf"
109
+
110
+ # Double check viewability / public domain
111
+ # If publicDomain is True, but there's no downloadLink, sometimes viewability allows reading
112
+ # But we only want to support downloading actual files.
113
+ # So we stick to epub/pdf downloadLink availability.
114
+
115
+ return BookMetadata(
116
+ id=book_id,
117
+ title=title,
118
+ subtitle=subtitle,
119
+ authors=authors,
120
+ description=description,
121
+ language=language,
122
+ publisher=publisher,
123
+ published_year=published_year,
124
+ pages=pages,
125
+ isbn=isbn,
126
+ cover_url=cover_url,
127
+ download_url=download_url,
128
+ source=self.name,
129
+ download_availability=download_available,
130
+ file_format=file_format
131
+ )
132
+
133
+ async def search(
134
+ self,
135
+ query: str,
136
+ author: Optional[str] = None,
137
+ isbn: Optional[str] = None,
138
+ publisher: Optional[str] = None,
139
+ subject: Optional[str] = None
140
+ ) -> List[BookMetadata]:
141
+ """Searches Google Books API."""
142
+ q_param = self._build_query(query, author, isbn, publisher, subject)
143
+ if not q_param:
144
+ return []
145
+
146
+ params = {
147
+ "q": q_param,
148
+ "maxResults": 20,
149
+ }
150
+
151
+ try:
152
+ async with httpx.AsyncClient(timeout=self.timeout_seconds) as client:
153
+ response = await client.get(self.base_url, params=params)
154
+ if response.status_code != 200:
155
+ raise ProviderError(f"Google Books API returned status {response.status_code}")
156
+
157
+ data = response.json()
158
+ items = data.get("items", [])
159
+
160
+ results = []
161
+ for item in items:
162
+ try:
163
+ results.append(self._map_volume_to_metadata(item))
164
+ except Exception as parse_err:
165
+ logger.warning("Error parsing Google Book volume: %s", parse_err)
166
+ return results
167
+
168
+ except httpx.RequestError as e:
169
+ logger.error("HTTP error searching Google Books: %s", e)
170
+ raise ProviderError(f"Network error querying Google Books API: {e}")
171
+ except Exception as e:
172
+ logger.error("Error searching Google Books: %s", e)
173
+ raise ProviderError(f"Google Books search failed: {e}")
174
+
175
+ async def get_book(self, book_id: str) -> Optional[BookMetadata]:
176
+ """Fetches detailed volume metadata by volume ID."""
177
+ # Strips prefix e.g., google:abcde -> abcde
178
+ actual_id = book_id.split(":", 1)[-1]
179
+ url = f"{self.base_url}/{actual_id}"
180
+
181
+ try:
182
+ async with httpx.AsyncClient(timeout=self.timeout_seconds) as client:
183
+ response = await client.get(url)
184
+ if response.status_code == 404:
185
+ return None
186
+ if response.status_code != 200:
187
+ raise ProviderError(f"Google Books API returned status {response.status_code}")
188
+
189
+ return self._map_volume_to_metadata(response.json())
190
+ except httpx.RequestError as e:
191
+ raise ProviderError(f"Network error getting Google Book: {e}")
192
+ except Exception as e:
193
+ raise ProviderError(f"Google Books retrieval failed: {e}")
194
+
195
+ async def download(self, book: BookMetadata, dest_path: str) -> None:
196
+ """Downloads the Google Book. Delegates to central downloader."""
197
+ if not book.download_availability or not book.download_url:
198
+ raise DownloadError("No legal download URL available for this Google Book.")
199
+
200
+ # Import dynamically to avoid circular dependencies
201
+ from bookcli.downloader import download_file
202
+ await download_file(book.download_url, dest_path, self.timeout_seconds)