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.
@@ -0,0 +1,256 @@
1
+ """Open Library 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 OpenLibraryProvider(BaseProvider):
15
+ """Open Library API provider."""
16
+
17
+ def __init__(self, timeout_seconds: int = 15):
18
+ super().__init__(name="openlibrary", timeout_seconds=timeout_seconds)
19
+ self.search_url = "https://openlibrary.org/search.json"
20
+
21
+ def _map_doc_to_metadata(self, doc: dict) -> BookMetadata:
22
+ """Maps Open Library search document to BookMetadata."""
23
+ work_key = doc.get("key", "")
24
+ # Standardize ID: openlibrary:OL123W
25
+ book_id = f"openlibrary:{work_key.split('/')[-1]}"
26
+
27
+ title = doc.get("title", "Unknown Title")
28
+ subtitle = doc.get("subtitle")
29
+ authors = doc.get("author_name", [])
30
+
31
+ # Publishers (Open Library returns a list)
32
+ publishers = doc.get("publisher", [])
33
+ publisher = publishers[0] if publishers else None
34
+
35
+ published_year = doc.get("first_publish_year")
36
+ pages = doc.get("number_of_pages_median")
37
+
38
+ # Languages (Open Library returns ISO codes in list, e.g. ['eng'])
39
+ languages = doc.get("language", [])
40
+ language = languages[0] if languages else None
41
+
42
+ # ISBN list
43
+ isbns = doc.get("isbn", [])
44
+ isbn = isbns[0] if isbns else None
45
+
46
+ # Cover Image URL
47
+ cover_i = doc.get("cover_i")
48
+ cover_url = f"https://covers.openlibrary.org/b/id/{cover_i}-M.jpg" if cover_i else None
49
+
50
+ # Internet Archive IDs (ia) for downloads
51
+ ia_list = doc.get("ia", [])
52
+ download_available = False
53
+ download_url = None
54
+ file_format = None
55
+
56
+ if ia_list:
57
+ # We filter out items that don't look like standard IA IDs (e.g. starting with markers like 'lending')
58
+ valid_ia = [ia for ia in ia_list if not ia.startswith("lending:")]
59
+ if valid_ia:
60
+ ia_id = valid_ia[0]
61
+ # Default to epub for IA download
62
+ download_url = f"https://archive.org/download/{ia_id}/{ia_id}.epub"
63
+ download_available = True
64
+ file_format = "epub"
65
+
66
+ return BookMetadata(
67
+ id=book_id,
68
+ title=title,
69
+ subtitle=subtitle,
70
+ authors=authors,
71
+ description=None, # Search endpoint doesn't return full description; get_book will fetch it
72
+ language=language,
73
+ publisher=publisher,
74
+ published_year=published_year,
75
+ pages=pages,
76
+ isbn=isbn,
77
+ cover_url=cover_url,
78
+ download_url=download_url,
79
+ source=self.name,
80
+ download_availability=download_available,
81
+ file_format=file_format
82
+ )
83
+
84
+ async def search(
85
+ self,
86
+ query: str,
87
+ author: Optional[str] = None,
88
+ isbn: Optional[str] = None,
89
+ publisher: Optional[str] = None,
90
+ subject: Optional[str] = None
91
+ ) -> List[BookMetadata]:
92
+ """Searches Open Library."""
93
+ params = {}
94
+ if query.strip():
95
+ params["q"] = query.strip()
96
+ if author:
97
+ params["author"] = author
98
+ if isbn:
99
+ params["isbn"] = isbn
100
+ if publisher:
101
+ params["publisher"] = publisher
102
+ if subject:
103
+ params["subject"] = subject
104
+
105
+ if not params:
106
+ return []
107
+
108
+ params["limit"] = 20
109
+
110
+ try:
111
+ async with httpx.AsyncClient(timeout=self.timeout_seconds) as client:
112
+ response = await client.get(self.search_url, params=params)
113
+ if response.status_code != 200:
114
+ raise ProviderError(f"Open Library API returned status {response.status_code}")
115
+
116
+ data = response.json()
117
+ docs = data.get("docs", [])
118
+
119
+ results = []
120
+ for doc in docs:
121
+ try:
122
+ results.append(self._map_doc_to_metadata(doc))
123
+ except Exception as e:
124
+ logger.warning("Error parsing Open Library doc: %s", e)
125
+ return results
126
+
127
+ except httpx.RequestError as e:
128
+ logger.error("HTTP error searching Open Library: %s", e)
129
+ raise ProviderError(f"Network error querying Open Library API: {e}")
130
+ except Exception as e:
131
+ logger.error("Error searching Open Library: %s", e)
132
+ raise ProviderError(f"Open Library search failed: {e}")
133
+
134
+ async def get_book(self, book_id: str) -> Optional[BookMetadata]:
135
+ """Fetches details for a single work from Open Library."""
136
+ actual_id = book_id.split(":", 1)[-1]
137
+
138
+ # Work URL (fetch the work JSON, e.g. /works/OL123W.json)
139
+ work_url = f"https://openlibrary.org/works/{actual_id}.json"
140
+
141
+ try:
142
+ async with httpx.AsyncClient(timeout=self.timeout_seconds) as client:
143
+ # Fetch detailed Work JSON
144
+ work_response = await client.get(work_url)
145
+ if work_response.status_code == 404:
146
+ return None
147
+ if work_response.status_code != 200:
148
+ raise ProviderError(f"Open Library work endpoint returned {work_response.status_code}")
149
+
150
+ work_data = work_response.json()
151
+
152
+ # Get description
153
+ description = None
154
+ desc_field = work_data.get("description")
155
+ if isinstance(desc_field, dict):
156
+ description = desc_field.get("value")
157
+ elif isinstance(desc_field, str):
158
+ description = desc_field
159
+
160
+ # Get authors (works only contain references to author keys, e.g. {"author": {"key": "/authors/OL123A"}})
161
+ authors = []
162
+ for auth_entry in work_data.get("authors", []):
163
+ auth_key = auth_entry.get("author", {}).get("key")
164
+ if auth_key:
165
+ # Fetch author detail to get name
166
+ auth_response = await client.get(f"https://openlibrary.org{auth_key}.json")
167
+ if auth_response.status_code == 200:
168
+ authors.append(auth_response.json().get("name", ""))
169
+
170
+ # Fetch editions to find pages, publisher, ISBN, cover, and downloads
171
+ # /works/OL123W/editions.json
172
+ editions_url = f"https://openlibrary.org/works/{actual_id}/editions.json"
173
+ editions_response = await client.get(editions_url)
174
+
175
+ publisher = None
176
+ published_year = None
177
+ pages = None
178
+ isbn = None
179
+ cover_url = None
180
+ download_available = False
181
+ download_url = None
182
+ file_format = None
183
+
184
+ if editions_response.status_code == 200:
185
+ editions_data = editions_response.json()
186
+ entries = editions_data.get("entries", [])
187
+
188
+ if entries:
189
+ # Check the first edition with details
190
+ first_ed = entries[0]
191
+
192
+ publishers = first_ed.get("publishers", [])
193
+ publisher = publishers[0] if publishers else None
194
+
195
+ pub_date = first_ed.get("publish_date")
196
+ if pub_date:
197
+ # Try parsing year
198
+ try:
199
+ published_year = int(pub_date.split()[-1])
200
+ except ValueError:
201
+ pass
202
+
203
+ pages = first_ed.get("number_of_pages")
204
+
205
+ isbns = first_ed.get("isbn_13", []) or first_ed.get("isbn_10", [])
206
+ isbn = isbns[0] if isbns else None
207
+
208
+ # Cover URL if present
209
+ covers = first_ed.get("covers", [])
210
+ if covers:
211
+ cover_url = f"https://covers.openlibrary.org/b/id/{covers[0]}-M.jpg"
212
+
213
+ # Internet Archive IDs
214
+ ia_list = first_ed.get("ocaid") or first_ed.get("ia", [])
215
+ if isinstance(ia_list, str):
216
+ ia_list = [ia_list]
217
+
218
+ if ia_list:
219
+ valid_ia = [ia for ia in ia_list if not ia.startswith("lending:")]
220
+ if valid_ia:
221
+ ia_id = valid_ia[0]
222
+ download_url = f"https://archive.org/download/{ia_id}/{ia_id}.epub"
223
+ download_available = True
224
+ file_format = "epub"
225
+
226
+ return BookMetadata(
227
+ id=book_id,
228
+ title=work_data.get("title", "Unknown Title"),
229
+ subtitle=work_data.get("subtitle"),
230
+ authors=authors,
231
+ description=description,
232
+ language=None,
233
+ publisher=publisher,
234
+ published_year=published_year,
235
+ pages=pages,
236
+ isbn=isbn,
237
+ cover_url=cover_url,
238
+ download_url=download_url,
239
+ source=self.name,
240
+ download_availability=download_available,
241
+ file_format=file_format
242
+ )
243
+
244
+ except httpx.RequestError as e:
245
+ raise ProviderError(f"Network error getting Open Library Work: {e}")
246
+ except Exception as e:
247
+ raise ProviderError(f"Open Library Work retrieval failed: {e}")
248
+
249
+ async def download(self, book: BookMetadata, dest_path: str) -> None:
250
+ """Downloads the Open Library Book. Delegates to central downloader."""
251
+ if not book.download_availability or not book.download_url:
252
+ raise DownloadError("No legal download URL available for this Open Library book.")
253
+
254
+ # Import dynamically to avoid circular dependencies
255
+ from bookcli.downloader import download_file
256
+ await download_file(book.download_url, dest_path, self.timeout_seconds)
@@ -0,0 +1 @@
1
+ """Services for BookCLI."""
@@ -0,0 +1,69 @@
1
+ """Service for managing search history in the SQLite database."""
2
+
3
+ import logging
4
+ from typing import List, Dict, Any
5
+ from bookcli.database import DatabaseManager
6
+
7
+ logger = logging.getLogger(__name__)
8
+
9
+
10
+ async def add_search_history(
11
+ db_manager: DatabaseManager, query: str, results_count: int
12
+ ) -> None:
13
+ """Adds a search record to the search history table."""
14
+ insert_sql = """
15
+ INSERT INTO search_history (query, results_count)
16
+ VALUES (?, ?)
17
+ """
18
+ async with db_manager.connection() as conn:
19
+ try:
20
+ await conn.execute(insert_sql, (query, results_count))
21
+ await conn.commit()
22
+ logger.debug("Logged search query '%s' with %d results to history", query, results_count)
23
+ except Exception as e:
24
+ logger.error("Failed to write to search history: %s", e)
25
+
26
+
27
+ async def get_search_history(
28
+ db_manager: DatabaseManager, limit: int = 50
29
+ ) -> List[Dict[str, Any]]:
30
+ """Retrieves list of search history entries, sorted by most recent first."""
31
+ select_sql = """
32
+ SELECT id, query, timestamp, results_count
33
+ FROM search_history
34
+ ORDER BY timestamp DESC, id DESC
35
+ LIMIT ?
36
+ """
37
+ async with db_manager.connection() as conn:
38
+ try:
39
+ async with conn.execute(select_sql, (limit,)) as cursor:
40
+ rows = await cursor.fetchall()
41
+ return [
42
+ {
43
+ "id": row["id"],
44
+ "query": row["query"],
45
+ "timestamp": row["timestamp"],
46
+ "results_count": row["results_count"]
47
+ }
48
+ for row in rows
49
+ ]
50
+ except Exception as e:
51
+ logger.error("Failed to fetch search history: %s", e)
52
+ return []
53
+
54
+
55
+ async def clear_search_history(db_manager: DatabaseManager) -> int:
56
+ """Clears all search history records. Returns the number of deleted records."""
57
+ async with db_manager.connection() as conn:
58
+ try:
59
+ async with conn.execute("SELECT COUNT(*) FROM search_history") as cursor:
60
+ row = await cursor.fetchone()
61
+ count = row[0] if row else 0
62
+
63
+ await conn.execute("DELETE FROM search_history")
64
+ await conn.commit()
65
+ logger.info("Cleared search history database (%d items).", count)
66
+ return count
67
+ except Exception as e:
68
+ logger.error("Failed to clear search history: %s", e)
69
+ return 0
@@ -0,0 +1,169 @@
1
+ """Service for ranking and deduplicating book search results."""
2
+
3
+ import re
4
+ from typing import List, Optional
5
+ from rapidfuzz import fuzz
6
+
7
+ from bookcli.models import BookMetadata
8
+
9
+
10
+ def normalize_isbn(isbn: Optional[str]) -> Optional[str]:
11
+ """Strips dashes, spaces, and lowercase 'x' to normalize ISBN."""
12
+ if not isbn:
13
+ return None
14
+ cleaned = re.sub(r"[- \s]", "", isbn).strip()
15
+ return cleaned.upper() if cleaned else None
16
+
17
+
18
+ def is_duplicate(book1: BookMetadata, book2: BookMetadata) -> bool:
19
+ """Determines if two books are duplicates based on ISBN or Title+Author similarity."""
20
+ # Check ISBN match
21
+ isbn1 = normalize_isbn(book1.isbn)
22
+ isbn2 = normalize_isbn(book2.isbn)
23
+ if isbn1 and isbn2 and isbn1 == isbn2:
24
+ return True
25
+
26
+ # Check Title + Author similarity
27
+ # We use fuzz.token_set_ratio for comparison to support subtitle variations
28
+ title_sim = fuzz.token_set_ratio(book1.title.lower(), book2.title.lower())
29
+
30
+ # Compare authors
31
+ authors1 = " ".join(book1.authors).lower()
32
+ authors2 = " ".join(book2.authors).lower()
33
+
34
+ if authors1 and authors2:
35
+ author_sim = fuzz.token_sort_ratio(authors1, authors2)
36
+ elif not authors1 and not authors2:
37
+ author_sim = 100.0 # both have no authors
38
+ else:
39
+ author_sim = 0.0
40
+
41
+ # If title similarity is very high and author matches reasonably well
42
+ if title_sim >= 85 and author_sim >= 80:
43
+ return True
44
+
45
+ return False
46
+
47
+
48
+ def merge_books(book1: BookMetadata, book2: BookMetadata) -> BookMetadata:
49
+ """Merges two duplicate books, prioritizing the one with download availability and richer metadata."""
50
+ # Determine primary book (prefer download availability, then longer description, then presence of cover)
51
+ if book1.download_availability and not book2.download_availability:
52
+ primary, secondary = book1, book2
53
+ elif book2.download_availability and not book1.download_availability:
54
+ primary, secondary = book2, book1
55
+ else:
56
+ desc1_len = len(book1.description or "")
57
+ desc2_len = len(book2.description or "")
58
+ if desc1_len >= desc2_len:
59
+ primary, secondary = book1, book2
60
+ else:
61
+ primary, secondary = book2, book1
62
+
63
+ # Merge fields
64
+ merged_authors = list(primary.authors)
65
+ for auth in secondary.authors:
66
+ if auth not in merged_authors:
67
+ merged_authors.append(auth)
68
+
69
+ return BookMetadata(
70
+ id=primary.id,
71
+ title=primary.title,
72
+ subtitle=primary.subtitle or secondary.subtitle,
73
+ authors=merged_authors,
74
+ description=primary.description or secondary.description,
75
+ language=primary.language or secondary.language,
76
+ publisher=primary.publisher or secondary.publisher,
77
+ published_year=primary.published_year or secondary.published_year,
78
+ pages=primary.pages or secondary.pages,
79
+ isbn=primary.isbn or secondary.isbn,
80
+ cover_url=primary.cover_url or secondary.cover_url,
81
+ download_url=primary.download_url or secondary.download_url,
82
+ source=f"{primary.source}, {secondary.source}" if primary.source != secondary.source else primary.source,
83
+ download_availability=primary.download_availability or secondary.download_availability,
84
+ file_format=primary.file_format or secondary.file_format
85
+ )
86
+
87
+
88
+ def calculate_score(
89
+ book: BookMetadata,
90
+ query: str,
91
+ author: Optional[str] = None,
92
+ isbn: Optional[str] = None
93
+ ) -> float:
94
+ """Calculates a relevance score for a book based on search query and filters."""
95
+ score = 0.0
96
+
97
+ # 1. Exact ISBN Match (Max weight)
98
+ if isbn:
99
+ norm_query_isbn = normalize_isbn(isbn)
100
+ norm_book_isbn = normalize_isbn(book.isbn)
101
+ if norm_query_isbn and norm_book_isbn and norm_query_isbn == norm_book_isbn:
102
+ score += 200.0
103
+
104
+ # 2. Fuzzy Title Match
105
+ if query.strip():
106
+ # Title token sort ratio
107
+ title_ratio = fuzz.token_sort_ratio(query.lower(), book.title.lower())
108
+ # Title partial ratio (helps when query is a substring)
109
+ title_partial = fuzz.partial_ratio(query.lower(), book.title.lower())
110
+ score += (title_ratio * 0.6) + (title_partial * 0.4)
111
+
112
+ # 3. Fuzzy Author Match
113
+ book_authors_str = " ".join(book.authors).lower()
114
+ if author and book_authors_str:
115
+ author_ratio = fuzz.token_sort_ratio(author.lower(), book_authors_str)
116
+ score += author_ratio * 0.8
117
+ elif query.strip() and book_authors_str:
118
+ # Check if the query itself matches the author name
119
+ author_query_ratio = fuzz.token_sort_ratio(query.lower(), book_authors_str)
120
+ # If query matches author, add a moderate weight
121
+ if author_query_ratio > 70:
122
+ score += author_query_ratio * 0.4
123
+
124
+ # 4. Download Availability Bonus
125
+ if book.download_availability:
126
+ score += 15.0
127
+
128
+ # 5. Publisher Match (bonus if publisher was in query terms)
129
+ if query.strip() and book.publisher:
130
+ pub_ratio = fuzz.partial_ratio(query.lower(), book.publisher.lower())
131
+ if pub_ratio > 80:
132
+ score += 5.0
133
+
134
+ return score
135
+
136
+
137
+ def rank_and_deduplicate(
138
+ books: List[BookMetadata],
139
+ query: str,
140
+ author: Optional[str] = None,
141
+ isbn: Optional[str] = None
142
+ ) -> List[BookMetadata]:
143
+ """Deduplicates and ranks a list of book metadata objects."""
144
+ # Deduplicate in-memory
145
+ unique_books: List[BookMetadata] = []
146
+
147
+ for book in books:
148
+ found_dup_idx = -1
149
+ for idx, u_book in enumerate(unique_books):
150
+ if is_duplicate(book, u_book):
151
+ found_dup_idx = idx
152
+ break
153
+
154
+ if found_dup_idx >= 0:
155
+ # Merge duplicate
156
+ unique_books[found_dup_idx] = merge_books(unique_books[found_dup_idx], book)
157
+ else:
158
+ unique_books.append(book)
159
+
160
+ # Calculate score and sort
161
+ # We sort descending by score
162
+ scored_books = [
163
+ (calculate_score(book, query, author, isbn), book)
164
+ for book in unique_books
165
+ ]
166
+
167
+ scored_books.sort(key=lambda x: x[0], reverse=True)
168
+
169
+ return [book for _, book in scored_books]
@@ -0,0 +1,96 @@
1
+ """Service coordinating concurrent search across multiple providers."""
2
+
3
+ import asyncio
4
+ import logging
5
+ from typing import List, Optional
6
+
7
+ from bookcli.config import AppConfig
8
+ from bookcli.database import DatabaseManager
9
+ from bookcli.models import BookMetadata
10
+ from bookcli.cache import save_book_to_cache
11
+ from bookcli.services.ranking import rank_and_deduplicate
12
+ from bookcli.services.history import add_search_history
13
+ from bookcli.providers.google_books import GoogleBooksProvider
14
+ from bookcli.providers.openlibrary import OpenLibraryProvider
15
+ from bookcli.providers.gutenberg import GutenbergProvider
16
+ from bookcli.providers.internet_archive import InternetArchiveProvider
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+
21
+ async def search_books(
22
+ query: str,
23
+ config: AppConfig,
24
+ db_manager: DatabaseManager,
25
+ author: Optional[str] = None,
26
+ isbn: Optional[str] = None,
27
+ publisher: Optional[str] = None,
28
+ subject: Optional[str] = None
29
+ ) -> List[BookMetadata]:
30
+ """Searches concurrently across all enabled book search providers, merges and ranks results.
31
+
32
+ Saves results to cache and logs the search query to history.
33
+ """
34
+ providers = []
35
+
36
+ # Instantiate only enabled providers
37
+ if config.providers.google_books:
38
+ providers.append(GoogleBooksProvider(timeout_seconds=config.timeout_seconds))
39
+ if config.providers.openlibrary:
40
+ providers.append(OpenLibraryProvider(timeout_seconds=config.timeout_seconds))
41
+ if config.providers.gutenberg:
42
+ providers.append(GutenbergProvider(timeout_seconds=config.timeout_seconds))
43
+ if config.providers.internet_archive:
44
+ providers.append(InternetArchiveProvider(timeout_seconds=config.timeout_seconds))
45
+
46
+ if not providers:
47
+ logger.warning("No search providers are enabled.")
48
+ return []
49
+
50
+ # Run searches concurrently
51
+ tasks = [
52
+ provider.search(
53
+ query=query,
54
+ author=author,
55
+ isbn=isbn,
56
+ publisher=publisher,
57
+ subject=subject
58
+ )
59
+ for provider in providers
60
+ ]
61
+
62
+ # Gather with return_exceptions=True to avoid one provider crash blocking others
63
+ search_results = await asyncio.gather(*tasks, return_exceptions=True)
64
+
65
+ all_books: List[BookMetadata] = []
66
+ for provider, result in zip(providers, search_results):
67
+ if isinstance(result, Exception):
68
+ logger.error("Provider %s search failed: %s", provider.name, result)
69
+ elif result:
70
+ all_books.extend(result)
71
+
72
+ # Rank and deduplicate
73
+ ranked_books = rank_and_deduplicate(
74
+ books=all_books,
75
+ query=query,
76
+ author=author,
77
+ isbn=isbn
78
+ )
79
+
80
+ # Cache metadata for each book (so it can be retrieved via book info <id> or book download <id> instantly)
81
+ # This also allows offline metadata lookup.
82
+ cache_tasks = [save_book_to_cache(db_manager, book) for book in ranked_books]
83
+ if cache_tasks:
84
+ await asyncio.gather(*cache_tasks, return_exceptions=True)
85
+
86
+ # Log to history
87
+ try:
88
+ await add_search_history(
89
+ db_manager=db_manager,
90
+ query=query or f"[author:{author or ''} isbn:{isbn or ''} pub:{publisher or ''} subj:{subject or ''}]",
91
+ results_count=len(ranked_books)
92
+ )
93
+ except Exception as history_err:
94
+ logger.error("Failed to write search history: %s", history_err)
95
+
96
+ return ranked_books
bookcli/settings.py ADDED
@@ -0,0 +1,28 @@
1
+ """Default settings and constants for BookCLI."""
2
+
3
+ import os
4
+ from pathlib import Path
5
+
6
+ # Base Directory ~/.bookcli
7
+ BASE_DIR = Path(os.environ.get("BOOKCLI_HOME", Path.home() / ".bookcli"))
8
+ BASE_DIR.mkdir(parents=True, exist_ok=True)
9
+
10
+ # File Paths
11
+ DB_PATH = BASE_DIR / "bookcli.db"
12
+ CONFIG_PATH = BASE_DIR / "config.json"
13
+ DEFAULT_DOWNLOAD_DIR = BASE_DIR / "downloads"
14
+ DEFAULT_DOWNLOAD_DIR.mkdir(parents=True, exist_ok=True)
15
+
16
+ # Default configuration settings
17
+ DEFAULT_CONFIG = {
18
+ "download_dir": str(DEFAULT_DOWNLOAD_DIR),
19
+ "cache_ttl_seconds": 86400, # 1 day
20
+ "timeout_seconds": 15,
21
+ "theme": "dark",
22
+ "providers": {
23
+ "google_books": True,
24
+ "openlibrary": True,
25
+ "gutenberg": True,
26
+ "internet_archive": True
27
+ }
28
+ }
bookcli/utils.py ADDED
@@ -0,0 +1,30 @@
1
+ """General utility helper functions for formatting and CLI presentation."""
2
+
3
+ from typing import Union
4
+
5
+
6
+ def format_size(size_in_bytes: Union[int, float]) -> str:
7
+ """Formats raw byte sizes into human-readable strings (KB, MB, GB)."""
8
+ if size_in_bytes < 1024:
9
+ return f"{size_in_bytes} B"
10
+ elif size_in_bytes < 1024 * 1024:
11
+ return f"{size_in_bytes / 1024:.2f} KB"
12
+ elif size_in_bytes < 1024 * 1024 * 1024:
13
+ return f"{size_in_bytes / (1024 * 1024):.2f} MB"
14
+ else:
15
+ return f"{size_in_bytes / (1024 * 1024 * 1024):.2f} GB"
16
+
17
+
18
+ def format_speed(bytes_per_second: Union[int, float]) -> str:
19
+ """Formats transfer speed into human-readable strings (KB/s, MB/s)."""
20
+ return f"{format_size(bytes_per_second)}/s"
21
+
22
+
23
+ def clean_filename(title: str) -> str:
24
+ """Cleans a string to make it safe for a filename."""
25
+ # Strip non-alphanumeric, spaces, underscores, dashes
26
+ cleaned = "".join(c for c in title if c.isalnum() or c in (" ", "_", "-")).strip()
27
+ # Replace spaces with underscores
28
+ cleaned = cleaned.replace(" ", "_")
29
+ # Limit length
30
+ return cleaned[:100]