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/__init__.py +3 -0
- bookcli/cache.py +103 -0
- bookcli/cli.py +752 -0
- bookcli/config.py +72 -0
- bookcli/database/__init__.py +32 -0
- bookcli/database/migrations.py +94 -0
- bookcli/downloader.py +130 -0
- bookcli/exceptions.py +34 -0
- bookcli/models.py +23 -0
- bookcli/opener.py +32 -0
- bookcli/providers/__init__.py +1 -0
- bookcli/providers/base.py +61 -0
- bookcli/providers/google_books.py +202 -0
- bookcli/providers/gutenberg.py +211 -0
- bookcli/providers/internet_archive.py +266 -0
- bookcli/providers/openlibrary.py +256 -0
- bookcli/services/__init__.py +1 -0
- bookcli/services/history.py +69 -0
- bookcli/services/ranking.py +169 -0
- bookcli/services/search.py +96 -0
- bookcli/settings.py +28 -0
- bookcli/utils.py +30 -0
- bookcli-0.1.0.dist-info/METADATA +198 -0
- bookcli-0.1.0.dist-info/RECORD +27 -0
- bookcli-0.1.0.dist-info/WHEEL +5 -0
- bookcli-0.1.0.dist-info/entry_points.txt +2 -0
- bookcli-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
"""Project Gutenberg (via Gutendex 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 GutenbergProvider(BaseProvider):
|
|
15
|
+
"""Project Gutenberg provider using Gutendex API."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, timeout_seconds: int = 15):
|
|
18
|
+
super().__init__(name="gutenberg", timeout_seconds=timeout_seconds)
|
|
19
|
+
self.base_url = "https://gutendex.com/books/"
|
|
20
|
+
|
|
21
|
+
def _format_author_name(self, name: str) -> str:
|
|
22
|
+
"""Converts 'Austen, Jane' to 'Jane Austen'."""
|
|
23
|
+
if "," in name:
|
|
24
|
+
parts = name.split(",", 1)
|
|
25
|
+
return f"{parts[1].strip()} {parts[0].strip()}"
|
|
26
|
+
return name
|
|
27
|
+
|
|
28
|
+
def _map_item_to_metadata(self, item: dict) -> BookMetadata:
|
|
29
|
+
"""Maps Gutendex book to BookMetadata."""
|
|
30
|
+
book_id = f"gutenberg:{item.get('id')}"
|
|
31
|
+
title = item.get("title", "Unknown Gutenberg Book")
|
|
32
|
+
|
|
33
|
+
# Authors
|
|
34
|
+
raw_authors = item.get("authors", [])
|
|
35
|
+
authors = [self._format_author_name(a.get("name", "")) for a in raw_authors if a.get("name")]
|
|
36
|
+
|
|
37
|
+
# Description
|
|
38
|
+
subjects = item.get("subjects", [])
|
|
39
|
+
bookshelves = item.get("bookshelves", [])
|
|
40
|
+
description = f"Subjects: {', '.join(subjects)}"
|
|
41
|
+
if bookshelves:
|
|
42
|
+
description += f" | Bookshelves: {', '.join(bookshelves)}"
|
|
43
|
+
|
|
44
|
+
# Languages
|
|
45
|
+
languages = item.get("languages", [])
|
|
46
|
+
language = languages[0] if languages else "en"
|
|
47
|
+
|
|
48
|
+
# Formats and Cover
|
|
49
|
+
formats = item.get("formats", {})
|
|
50
|
+
|
|
51
|
+
# Look for cover URL
|
|
52
|
+
# Gutendex formats dict can contain images:
|
|
53
|
+
cover_url = None
|
|
54
|
+
for fmt, url in formats.items():
|
|
55
|
+
if "image/jpeg" in fmt and "cover.medium" in url:
|
|
56
|
+
cover_url = url
|
|
57
|
+
break
|
|
58
|
+
if not cover_url:
|
|
59
|
+
for fmt, url in formats.items():
|
|
60
|
+
if "image/jpeg" in fmt and "cover.small" in url:
|
|
61
|
+
cover_url = url
|
|
62
|
+
break
|
|
63
|
+
if not cover_url:
|
|
64
|
+
# Fallback to any jpeg image in formats
|
|
65
|
+
for fmt, url in formats.items():
|
|
66
|
+
if "image" in fmt:
|
|
67
|
+
cover_url = url
|
|
68
|
+
break
|
|
69
|
+
|
|
70
|
+
# Download Availability and URL
|
|
71
|
+
download_available = False
|
|
72
|
+
download_url = None
|
|
73
|
+
file_format = None
|
|
74
|
+
|
|
75
|
+
# Prefer epub
|
|
76
|
+
epub_key = "application/epub+zip"
|
|
77
|
+
txt_utf8_key = "text/plain; charset=utf-8"
|
|
78
|
+
txt_us_ascii_key = "text/plain; charset=us-ascii"
|
|
79
|
+
txt_key = "text/plain"
|
|
80
|
+
|
|
81
|
+
if epub_key in formats:
|
|
82
|
+
download_url = formats[epub_key]
|
|
83
|
+
download_available = True
|
|
84
|
+
file_format = "epub"
|
|
85
|
+
elif txt_utf8_key in formats:
|
|
86
|
+
download_url = formats[txt_utf8_key]
|
|
87
|
+
download_available = True
|
|
88
|
+
file_format = "txt"
|
|
89
|
+
elif txt_us_ascii_key in formats:
|
|
90
|
+
download_url = formats[txt_us_ascii_key]
|
|
91
|
+
download_available = True
|
|
92
|
+
file_format = "txt"
|
|
93
|
+
elif txt_key in formats:
|
|
94
|
+
download_url = formats[txt_key]
|
|
95
|
+
download_available = True
|
|
96
|
+
file_format = "txt"
|
|
97
|
+
else:
|
|
98
|
+
# Try to grab the first text/html or other formats
|
|
99
|
+
for fmt, url in formats.items():
|
|
100
|
+
if "epub" in fmt:
|
|
101
|
+
download_url = url
|
|
102
|
+
download_available = True
|
|
103
|
+
file_format = "epub"
|
|
104
|
+
break
|
|
105
|
+
elif "text" in fmt:
|
|
106
|
+
download_url = url
|
|
107
|
+
download_available = True
|
|
108
|
+
file_format = "txt"
|
|
109
|
+
break
|
|
110
|
+
|
|
111
|
+
return BookMetadata(
|
|
112
|
+
id=book_id,
|
|
113
|
+
title=title,
|
|
114
|
+
subtitle=None,
|
|
115
|
+
authors=authors,
|
|
116
|
+
description=description,
|
|
117
|
+
language=language,
|
|
118
|
+
publisher="Project Gutenberg",
|
|
119
|
+
published_year=None, # Gutenberg works are public domain reprints
|
|
120
|
+
pages=None,
|
|
121
|
+
isbn=None,
|
|
122
|
+
cover_url=cover_url,
|
|
123
|
+
download_url=download_url,
|
|
124
|
+
source=self.name,
|
|
125
|
+
download_availability=download_available,
|
|
126
|
+
file_format=file_format
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
async def search(
|
|
130
|
+
self,
|
|
131
|
+
query: str,
|
|
132
|
+
author: Optional[str] = None,
|
|
133
|
+
isbn: Optional[str] = None,
|
|
134
|
+
publisher: Optional[str] = None,
|
|
135
|
+
subject: Optional[str] = None
|
|
136
|
+
) -> List[BookMetadata]:
|
|
137
|
+
"""Searches Project Gutenberg books using Gutendex."""
|
|
138
|
+
# Gutendex supports a general search param
|
|
139
|
+
params = {}
|
|
140
|
+
|
|
141
|
+
# If query is provided, use search.
|
|
142
|
+
# If query is empty but we have author or subject, we search that.
|
|
143
|
+
search_terms = []
|
|
144
|
+
if query.strip():
|
|
145
|
+
search_terms.append(query.strip())
|
|
146
|
+
if author:
|
|
147
|
+
search_terms.append(author)
|
|
148
|
+
|
|
149
|
+
if search_terms:
|
|
150
|
+
params["search"] = " ".join(search_terms)
|
|
151
|
+
|
|
152
|
+
if subject:
|
|
153
|
+
params["topic"] = subject
|
|
154
|
+
|
|
155
|
+
# ISBN is not applicable to Gutenberg books generally, but if requested, we skip or search normally.
|
|
156
|
+
if isbn:
|
|
157
|
+
params["search"] = isbn
|
|
158
|
+
|
|
159
|
+
# Gutenberg publisher is always Gutenberg, so if publisher is requested and isn't gutenberg, we'd get 0 results.
|
|
160
|
+
if publisher and "gutenberg" not in publisher.lower():
|
|
161
|
+
return []
|
|
162
|
+
|
|
163
|
+
try:
|
|
164
|
+
async with httpx.AsyncClient(timeout=self.timeout_seconds, follow_redirects=True) as client:
|
|
165
|
+
response = await client.get(self.base_url, params=params)
|
|
166
|
+
if response.status_code != 200:
|
|
167
|
+
raise ProviderError(f"Gutendex API returned status {response.status_code}")
|
|
168
|
+
|
|
169
|
+
data = response.json()
|
|
170
|
+
results = []
|
|
171
|
+
for item in data.get("results", []):
|
|
172
|
+
try:
|
|
173
|
+
results.append(self._map_item_to_metadata(item))
|
|
174
|
+
except Exception as e:
|
|
175
|
+
logger.warning("Error parsing Gutendex book: %s", e)
|
|
176
|
+
return results
|
|
177
|
+
|
|
178
|
+
except httpx.RequestError as e:
|
|
179
|
+
logger.error("HTTP error querying Gutendex: %s", e)
|
|
180
|
+
raise ProviderError(f"Network error querying Gutendex API: {e}")
|
|
181
|
+
except Exception as e:
|
|
182
|
+
logger.error("Error searching Gutendex: %s", e)
|
|
183
|
+
raise ProviderError(f"Gutenberg search failed: {e}")
|
|
184
|
+
|
|
185
|
+
async def get_book(self, book_id: str) -> Optional[BookMetadata]:
|
|
186
|
+
"""Gets detailed metadata for a single Gutenberg book by its ID."""
|
|
187
|
+
actual_id = book_id.split(":", 1)[-1]
|
|
188
|
+
url = f"{self.base_url}/{actual_id}"
|
|
189
|
+
|
|
190
|
+
try:
|
|
191
|
+
async with httpx.AsyncClient(timeout=self.timeout_seconds, follow_redirects=True) as client:
|
|
192
|
+
response = await client.get(url)
|
|
193
|
+
if response.status_code == 404:
|
|
194
|
+
return None
|
|
195
|
+
if response.status_code != 200:
|
|
196
|
+
raise ProviderError(f"Gutendex book endpoint returned status {response.status_code}")
|
|
197
|
+
|
|
198
|
+
return self._map_item_to_metadata(response.json())
|
|
199
|
+
except httpx.RequestError as e:
|
|
200
|
+
raise ProviderError(f"Network error getting Gutenberg book: {e}")
|
|
201
|
+
except Exception as e:
|
|
202
|
+
raise ProviderError(f"Gutenberg book retrieval failed: {e}")
|
|
203
|
+
|
|
204
|
+
async def download(self, book: BookMetadata, dest_path: str) -> None:
|
|
205
|
+
"""Downloads the Gutenberg book. Delegates to central downloader."""
|
|
206
|
+
if not book.download_availability or not book.download_url:
|
|
207
|
+
raise DownloadError("No download URL available for this Gutenberg book.")
|
|
208
|
+
|
|
209
|
+
# Import dynamically to avoid circular dependencies
|
|
210
|
+
from bookcli.downloader import download_file
|
|
211
|
+
await download_file(book.download_url, dest_path, self.timeout_seconds)
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
"""Internet Archive 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 InternetArchiveProvider(BaseProvider):
|
|
15
|
+
"""Internet Archive provider using the Advanced Search and Metadata APIs."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, timeout_seconds: int = 15):
|
|
18
|
+
super().__init__(name="internet_archive", timeout_seconds=timeout_seconds)
|
|
19
|
+
self.search_url = "https://archive.org/advancedsearch.php"
|
|
20
|
+
self.metadata_url = "https://archive.org/metadata"
|
|
21
|
+
|
|
22
|
+
def _build_query(
|
|
23
|
+
self,
|
|
24
|
+
query: str,
|
|
25
|
+
author: Optional[str] = None,
|
|
26
|
+
isbn: Optional[str] = None,
|
|
27
|
+
publisher: Optional[str] = None,
|
|
28
|
+
subject: Optional[str] = None
|
|
29
|
+
) -> str:
|
|
30
|
+
"""Builds an Lucene-style search query for the Internet Archive API."""
|
|
31
|
+
parts = ["mediatype:texts"]
|
|
32
|
+
|
|
33
|
+
if query.strip():
|
|
34
|
+
# Escape queries or keep them simple. Quoting title search terms is safer.
|
|
35
|
+
parts.append(f'(title:("{query}") OR "{query}")')
|
|
36
|
+
if author:
|
|
37
|
+
parts.append(f'creator:("{author}")')
|
|
38
|
+
if isbn:
|
|
39
|
+
# IA occasionally indexes isbn under isbn field
|
|
40
|
+
parts.append(f'isbn:("{isbn}")')
|
|
41
|
+
if publisher:
|
|
42
|
+
parts.append(f'publisher:("{publisher}")')
|
|
43
|
+
if subject:
|
|
44
|
+
parts.append(f'subject:("{subject}")')
|
|
45
|
+
|
|
46
|
+
return " AND ".join(parts)
|
|
47
|
+
|
|
48
|
+
def _parse_creator(self, creator_field: Optional[object]) -> List[str]:
|
|
49
|
+
"""Creator field in IA can be a string, a list of strings, or None."""
|
|
50
|
+
if not creator_field:
|
|
51
|
+
return []
|
|
52
|
+
if isinstance(creator_field, list):
|
|
53
|
+
return [str(c) for c in creator_field if c]
|
|
54
|
+
if isinstance(creator_field, str):
|
|
55
|
+
return [creator_field]
|
|
56
|
+
return [str(creator_field)]
|
|
57
|
+
|
|
58
|
+
def _parse_year(self, date_field: Optional[str]) -> Optional[int]:
|
|
59
|
+
"""Extracts the year from '2005-10-12T00:00:00Z' or '2005'."""
|
|
60
|
+
if not date_field:
|
|
61
|
+
return None
|
|
62
|
+
try:
|
|
63
|
+
# Take the first 4 characters
|
|
64
|
+
year = date_field[:4]
|
|
65
|
+
return int(year)
|
|
66
|
+
except (ValueError, IndexError):
|
|
67
|
+
return None
|
|
68
|
+
|
|
69
|
+
def _map_doc_to_metadata(self, doc: dict) -> BookMetadata:
|
|
70
|
+
"""Maps an IA search document to BookMetadata."""
|
|
71
|
+
identifier = doc.get("identifier", "")
|
|
72
|
+
book_id = f"internet_archive:{identifier}"
|
|
73
|
+
|
|
74
|
+
title = doc.get("title", "Unknown Title")
|
|
75
|
+
authors = self._parse_creator(doc.get("creator"))
|
|
76
|
+
publisher = doc.get("publisher")
|
|
77
|
+
if isinstance(publisher, list) and publisher:
|
|
78
|
+
publisher = publisher[0]
|
|
79
|
+
|
|
80
|
+
published_year = self._parse_year(doc.get("date"))
|
|
81
|
+
description = doc.get("description")
|
|
82
|
+
if isinstance(description, list) and description:
|
|
83
|
+
description = description[0]
|
|
84
|
+
|
|
85
|
+
language = doc.get("language")
|
|
86
|
+
if isinstance(language, list) and language:
|
|
87
|
+
language = language[0]
|
|
88
|
+
|
|
89
|
+
# Isbns (IA sometimes has list or string)
|
|
90
|
+
isbn_val = doc.get("isbn")
|
|
91
|
+
isbn = None
|
|
92
|
+
if isbn_val:
|
|
93
|
+
if isinstance(isbn_val, list):
|
|
94
|
+
isbn = isbn_val[0]
|
|
95
|
+
else:
|
|
96
|
+
isbn = str(isbn_val)
|
|
97
|
+
|
|
98
|
+
# Cover Image URL is standard for IA
|
|
99
|
+
cover_url = f"https://archive.org/services/img/{identifier}" if identifier else None
|
|
100
|
+
|
|
101
|
+
# Determine download availability
|
|
102
|
+
# We check the 'format' field if returned
|
|
103
|
+
formats = doc.get("format", [])
|
|
104
|
+
if isinstance(formats, str):
|
|
105
|
+
formats = [formats]
|
|
106
|
+
|
|
107
|
+
download_available = False
|
|
108
|
+
download_url = None
|
|
109
|
+
file_format = None
|
|
110
|
+
|
|
111
|
+
# Check if epub or pdf formats are present
|
|
112
|
+
has_epub = any("EPUB" in fmt.upper() for fmt in formats)
|
|
113
|
+
has_pdf = any("PDF" in fmt.upper() for fmt in formats)
|
|
114
|
+
|
|
115
|
+
if has_epub:
|
|
116
|
+
download_url = f"https://archive.org/download/{identifier}/{identifier}.epub"
|
|
117
|
+
download_available = True
|
|
118
|
+
file_format = "epub"
|
|
119
|
+
elif has_pdf:
|
|
120
|
+
download_url = f"https://archive.org/download/{identifier}/{identifier}.pdf"
|
|
121
|
+
download_available = True
|
|
122
|
+
file_format = "pdf"
|
|
123
|
+
|
|
124
|
+
return BookMetadata(
|
|
125
|
+
id=book_id,
|
|
126
|
+
title=title,
|
|
127
|
+
subtitle=None,
|
|
128
|
+
authors=authors,
|
|
129
|
+
description=description,
|
|
130
|
+
language=language,
|
|
131
|
+
publisher=publisher,
|
|
132
|
+
published_year=published_year,
|
|
133
|
+
pages=None,
|
|
134
|
+
isbn=isbn,
|
|
135
|
+
cover_url=cover_url,
|
|
136
|
+
download_url=download_url,
|
|
137
|
+
source=self.name,
|
|
138
|
+
download_availability=download_available,
|
|
139
|
+
file_format=file_format
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
async def search(
|
|
143
|
+
self,
|
|
144
|
+
query: str,
|
|
145
|
+
author: Optional[str] = None,
|
|
146
|
+
isbn: Optional[str] = None,
|
|
147
|
+
publisher: Optional[str] = None,
|
|
148
|
+
subject: Optional[str] = None
|
|
149
|
+
) -> List[BookMetadata]:
|
|
150
|
+
"""Searches Internet Archive advanced search."""
|
|
151
|
+
q_param = self._build_query(query, author, isbn, publisher, subject)
|
|
152
|
+
if not q_param:
|
|
153
|
+
return []
|
|
154
|
+
|
|
155
|
+
params = {
|
|
156
|
+
"q": q_param,
|
|
157
|
+
"fl[]": [
|
|
158
|
+
"identifier", "title", "creator", "publisher",
|
|
159
|
+
"date", "language", "description", "isbn", "format"
|
|
160
|
+
],
|
|
161
|
+
"rows": 20,
|
|
162
|
+
"output": "json"
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
try:
|
|
166
|
+
async with httpx.AsyncClient(timeout=self.timeout_seconds) as client:
|
|
167
|
+
response = await client.get(self.search_url, params=params)
|
|
168
|
+
if response.status_code != 200:
|
|
169
|
+
raise ProviderError(f"Internet Archive API returned status {response.status_code}")
|
|
170
|
+
|
|
171
|
+
data = response.json()
|
|
172
|
+
response_data = data.get("response", {})
|
|
173
|
+
docs = response_data.get("docs", [])
|
|
174
|
+
|
|
175
|
+
results = []
|
|
176
|
+
for doc in docs:
|
|
177
|
+
try:
|
|
178
|
+
results.append(self._map_doc_to_metadata(doc))
|
|
179
|
+
except Exception as e:
|
|
180
|
+
logger.warning("Error parsing Internet Archive doc: %s", e)
|
|
181
|
+
return results
|
|
182
|
+
|
|
183
|
+
except httpx.RequestError as e:
|
|
184
|
+
logger.error("HTTP error searching Internet Archive: %s", e)
|
|
185
|
+
raise ProviderError(f"Network error querying Internet Archive: {e}")
|
|
186
|
+
except Exception as e:
|
|
187
|
+
logger.error("Error searching Internet Archive: %s", e)
|
|
188
|
+
raise ProviderError(f"Internet Archive search failed: {e}")
|
|
189
|
+
|
|
190
|
+
async def get_book(self, book_id: str) -> Optional[BookMetadata]:
|
|
191
|
+
"""Gets detailed metadata from the IA Metadata API."""
|
|
192
|
+
actual_id = book_id.split(":", 1)[-1]
|
|
193
|
+
url = f"{self.metadata_url}/{actual_id}"
|
|
194
|
+
|
|
195
|
+
try:
|
|
196
|
+
async with httpx.AsyncClient(timeout=self.timeout_seconds) as client:
|
|
197
|
+
response = await client.get(url)
|
|
198
|
+
if response.status_code == 404:
|
|
199
|
+
return None
|
|
200
|
+
if response.status_code != 200:
|
|
201
|
+
raise ProviderError(f"Internet Archive Metadata API returned status {response.status_code}")
|
|
202
|
+
|
|
203
|
+
data = response.json()
|
|
204
|
+
metadata = data.get("metadata", {})
|
|
205
|
+
|
|
206
|
+
# Formats are in 'files' list in metadata API
|
|
207
|
+
files = data.get("files", [])
|
|
208
|
+
formats = [f.get("format", "") for f in files if f.get("format")]
|
|
209
|
+
|
|
210
|
+
# Construct standard doc for mapping
|
|
211
|
+
doc = {
|
|
212
|
+
"identifier": actual_id,
|
|
213
|
+
"title": metadata.get("title", ["Unknown Title"])[0] if isinstance(metadata.get("title"), list) else metadata.get("title"),
|
|
214
|
+
"creator": metadata.get("creator"),
|
|
215
|
+
"publisher": metadata.get("publisher"),
|
|
216
|
+
"date": metadata.get("date", [""])[0] if isinstance(metadata.get("date"), list) else metadata.get("date"),
|
|
217
|
+
"language": metadata.get("language"),
|
|
218
|
+
"description": metadata.get("description"),
|
|
219
|
+
"isbn": metadata.get("isbn"),
|
|
220
|
+
"format": formats
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
book = self._map_doc_to_metadata(doc)
|
|
224
|
+
|
|
225
|
+
# Override guessed download URL with exact filename from metadata
|
|
226
|
+
epub_name = None
|
|
227
|
+
pdf_name = None
|
|
228
|
+
for f in files:
|
|
229
|
+
name = f.get("name", "")
|
|
230
|
+
fmt = f.get("format", "").upper()
|
|
231
|
+
if "EPUB" in fmt and name.lower().endswith(".epub"):
|
|
232
|
+
epub_name = name
|
|
233
|
+
elif "PDF" in fmt and name.lower().endswith(".pdf"):
|
|
234
|
+
pdf_name = name
|
|
235
|
+
|
|
236
|
+
import urllib.parse
|
|
237
|
+
if epub_name:
|
|
238
|
+
safe_name = urllib.parse.quote(epub_name)
|
|
239
|
+
book.download_url = f"https://archive.org/download/{actual_id}/{safe_name}"
|
|
240
|
+
book.download_availability = True
|
|
241
|
+
book.file_format = "epub"
|
|
242
|
+
elif pdf_name:
|
|
243
|
+
safe_name = urllib.parse.quote(pdf_name)
|
|
244
|
+
book.download_url = f"https://archive.org/download/{actual_id}/{safe_name}"
|
|
245
|
+
book.download_availability = True
|
|
246
|
+
book.file_format = "pdf"
|
|
247
|
+
else:
|
|
248
|
+
book.download_url = None
|
|
249
|
+
book.download_availability = False
|
|
250
|
+
book.file_format = None
|
|
251
|
+
|
|
252
|
+
return book
|
|
253
|
+
|
|
254
|
+
except httpx.RequestError as e:
|
|
255
|
+
raise ProviderError(f"Network error getting IA metadata: {e}")
|
|
256
|
+
except Exception as e:
|
|
257
|
+
raise ProviderError(f"Internet Archive metadata retrieval failed: {e}")
|
|
258
|
+
|
|
259
|
+
async def download(self, book: BookMetadata, dest_path: str) -> None:
|
|
260
|
+
"""Downloads the Internet Archive book. Delegates to central downloader."""
|
|
261
|
+
if not book.download_availability or not book.download_url:
|
|
262
|
+
raise DownloadError("No download URL available for this Internet Archive book.")
|
|
263
|
+
|
|
264
|
+
# Import dynamically to avoid circular dependencies
|
|
265
|
+
from bookcli.downloader import download_file
|
|
266
|
+
await download_file(book.download_url, dest_path, self.timeout_seconds)
|