tbr-deal-finder 0.2.0__py3-none-any.whl → 0.3.1__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.
- tbr_deal_finder/__init__.py +1 -5
- tbr_deal_finder/__main__.py +7 -0
- tbr_deal_finder/book.py +28 -8
- tbr_deal_finder/cli.py +15 -28
- tbr_deal_finder/config.py +3 -3
- tbr_deal_finder/desktop_updater.py +147 -0
- tbr_deal_finder/gui/__init__.py +0 -0
- tbr_deal_finder/gui/main.py +725 -0
- tbr_deal_finder/gui/pages/__init__.py +1 -0
- tbr_deal_finder/gui/pages/all_books.py +93 -0
- tbr_deal_finder/gui/pages/all_deals.py +63 -0
- tbr_deal_finder/gui/pages/base_book_page.py +291 -0
- tbr_deal_finder/gui/pages/book_details.py +604 -0
- tbr_deal_finder/gui/pages/latest_deals.py +370 -0
- tbr_deal_finder/gui/pages/settings.py +389 -0
- tbr_deal_finder/migrations.py +26 -0
- tbr_deal_finder/queries/latest_unknown_book_sync.sql +5 -0
- tbr_deal_finder/retailer/amazon.py +60 -9
- tbr_deal_finder/retailer/amazon_custom_auth.py +79 -0
- tbr_deal_finder/retailer/audible.py +2 -1
- tbr_deal_finder/retailer/chirp.py +55 -11
- tbr_deal_finder/retailer/kindle.py +68 -44
- tbr_deal_finder/retailer/librofm.py +58 -21
- tbr_deal_finder/retailer/models.py +31 -1
- tbr_deal_finder/retailer_deal.py +62 -21
- tbr_deal_finder/tracked_books.py +76 -8
- tbr_deal_finder/utils.py +64 -2
- tbr_deal_finder/version_check.py +40 -0
- {tbr_deal_finder-0.2.0.dist-info → tbr_deal_finder-0.3.1.dist-info}/METADATA +19 -88
- tbr_deal_finder-0.3.1.dist-info/RECORD +38 -0
- {tbr_deal_finder-0.2.0.dist-info → tbr_deal_finder-0.3.1.dist-info}/entry_points.txt +1 -0
- tbr_deal_finder-0.2.0.dist-info/RECORD +0 -24
- {tbr_deal_finder-0.2.0.dist-info → tbr_deal_finder-0.3.1.dist-info}/WHEEL +0 -0
- {tbr_deal_finder-0.2.0.dist-info → tbr_deal_finder-0.3.1.dist-info}/licenses/LICENSE +0 -0
tbr_deal_finder/__init__.py
CHANGED
tbr_deal_finder/book.py
CHANGED
@@ -8,7 +8,7 @@ from Levenshtein import ratio
|
|
8
8
|
from unidecode import unidecode
|
9
9
|
|
10
10
|
from tbr_deal_finder.config import Config
|
11
|
-
from tbr_deal_finder.utils import get_duckdb_conn, execute_query, get_query_by_name, echo_info
|
11
|
+
from tbr_deal_finder.utils import get_duckdb_conn, execute_query, get_query_by_name, echo_info, float_to_currency
|
12
12
|
|
13
13
|
_AUTHOR_RE = re.compile(r'[^a-zA-Z0-9]')
|
14
14
|
|
@@ -55,11 +55,10 @@ class Book:
|
|
55
55
|
self.format = format
|
56
56
|
|
57
57
|
def discount(self) -> int:
|
58
|
-
|
58
|
+
if not self.current_price:
|
59
|
+
return 100
|
59
60
|
|
60
|
-
|
61
|
-
def price_to_string(price: float) -> str:
|
62
|
-
return f"{Config.currency_symbol()}{price:.2f}"
|
61
|
+
return int((1 - self.current_price/self.list_price) * 100)
|
63
62
|
|
64
63
|
@property
|
65
64
|
def deal_id(self) -> str:
|
@@ -90,10 +89,10 @@ class Book:
|
|
90
89
|
self._list_price = round(price, 2)
|
91
90
|
|
92
91
|
def list_price_string(self):
|
93
|
-
return
|
92
|
+
return float_to_currency(self.list_price)
|
94
93
|
|
95
94
|
def current_price_string(self):
|
96
|
-
return
|
95
|
+
return float_to_currency(self.current_price)
|
97
96
|
|
98
97
|
def __str__(self) -> str:
|
99
98
|
price = self.current_price_string()
|
@@ -127,6 +126,15 @@ class Book:
|
|
127
126
|
"book_id": self.title_id,
|
128
127
|
}
|
129
128
|
|
129
|
+
def unknown_book_dict(self):
|
130
|
+
return {
|
131
|
+
"retailer": self.retailer,
|
132
|
+
"title": self.title,
|
133
|
+
"authors": self.authors,
|
134
|
+
"format": self.format.value,
|
135
|
+
"book_id": self.deal_id,
|
136
|
+
}
|
137
|
+
|
130
138
|
|
131
139
|
def get_deals_found_at(timepoint: datetime) -> list[Book]:
|
132
140
|
db_conn = get_duckdb_conn()
|
@@ -147,7 +155,11 @@ def get_active_deals() -> list[Book]:
|
|
147
155
|
return [Book(**book) for book in query_response]
|
148
156
|
|
149
157
|
|
150
|
-
def
|
158
|
+
def is_qualifying_deal(config: Config, book: Book) -> bool:
|
159
|
+
return book.current_price <= config.max_price and book.discount() >= config.min_discount
|
160
|
+
|
161
|
+
|
162
|
+
def print_books(config: Config, books: list[Book]):
|
151
163
|
audiobooks = [book for book in books if book.format == BookFormat.AUDIOBOOK]
|
152
164
|
audiobooks = sorted(audiobooks, key=lambda book: book.deal_id)
|
153
165
|
|
@@ -159,10 +171,16 @@ def print_books(books: list[Book]):
|
|
159
171
|
continue
|
160
172
|
|
161
173
|
init_book = books_in_format[0]
|
174
|
+
if not any(is_qualifying_deal(config, book) for book in books_in_format):
|
175
|
+
continue
|
176
|
+
|
162
177
|
echo_info(f"\n\n{init_book.format.value} Deals:")
|
163
178
|
|
164
179
|
prior_title_id = init_book.title_id
|
165
180
|
for book in books_in_format:
|
181
|
+
if not is_qualifying_deal(config, book):
|
182
|
+
continue
|
183
|
+
|
166
184
|
if prior_title_id != book.title_id:
|
167
185
|
prior_title_id = book.title_id
|
168
186
|
click.echo()
|
@@ -171,10 +189,12 @@ def print_books(books: list[Book]):
|
|
171
189
|
|
172
190
|
|
173
191
|
def get_full_title_str(title: str, authors: Union[list, str]) -> str:
|
192
|
+
title = get_normalized_title(title)
|
174
193
|
return f"{title}__{get_normalized_authors(authors)}"
|
175
194
|
|
176
195
|
|
177
196
|
def get_title_id(title: str, authors: Union[list, str], book_format: BookFormat) -> str:
|
197
|
+
title = get_normalized_title(title)
|
178
198
|
return f"{title}__{get_normalized_authors(authors)}__{book_format.value}"
|
179
199
|
|
180
200
|
|
tbr_deal_finder/cli.py
CHANGED
@@ -13,19 +13,20 @@ from tbr_deal_finder.migrations import make_migrations
|
|
13
13
|
from tbr_deal_finder.book import get_deals_found_at, print_books, get_active_deals
|
14
14
|
from tbr_deal_finder.retailer import RETAILER_MAP
|
15
15
|
from tbr_deal_finder.retailer_deal import get_latest_deals
|
16
|
-
from tbr_deal_finder.tracked_books import reprocess_incomplete_tbr_books
|
16
|
+
from tbr_deal_finder.tracked_books import reprocess_incomplete_tbr_books, clear_unknown_books
|
17
17
|
from tbr_deal_finder.utils import (
|
18
18
|
echo_err,
|
19
19
|
echo_info,
|
20
20
|
echo_success,
|
21
|
-
execute_query,
|
22
21
|
get_duckdb_conn,
|
23
|
-
|
22
|
+
get_latest_deal_last_ran
|
24
23
|
)
|
24
|
+
from tbr_deal_finder.version_check import notify_if_outdated
|
25
25
|
|
26
26
|
|
27
27
|
@click.group()
|
28
28
|
def cli():
|
29
|
+
notify_if_outdated()
|
29
30
|
make_migrations()
|
30
31
|
|
31
32
|
|
@@ -183,6 +184,7 @@ def setup():
|
|
183
184
|
# Retailers may have changed causing some books to need reprocessing
|
184
185
|
config = Config.load()
|
185
186
|
reprocess_incomplete_tbr_books(config)
|
187
|
+
clear_unknown_books()
|
186
188
|
|
187
189
|
|
188
190
|
@cli.command()
|
@@ -194,34 +196,13 @@ def latest_deals():
|
|
194
196
|
config = _set_config()
|
195
197
|
|
196
198
|
db_conn = get_duckdb_conn()
|
197
|
-
|
198
|
-
db_conn,
|
199
|
-
get_query_by_name("get_active_deals.sql")
|
200
|
-
)
|
201
|
-
last_ran = None if not results else results[0]["timepoint"]
|
199
|
+
last_ran = get_latest_deal_last_ran(db_conn)
|
202
200
|
min_age = config.run_time - timedelta(hours=8)
|
203
201
|
|
204
202
|
if not last_ran or last_ran < min_age:
|
205
|
-
|
206
|
-
asyncio.run(get_latest_deals(config))
|
207
|
-
except Exception as e:
|
208
|
-
ran_successfully = False
|
209
|
-
details = f"Error getting deals: {e}"
|
210
|
-
echo_err(details)
|
211
|
-
else:
|
212
|
-
ran_successfully = True
|
213
|
-
details = ""
|
214
|
-
|
215
|
-
# Save execution results
|
216
|
-
db_conn.execute(
|
217
|
-
"INSERT INTO latest_deal_run_history (timepoint, ran_successfully, details) VALUES (?, ?, ?)",
|
218
|
-
[config.run_time, ran_successfully, details]
|
219
|
-
)
|
220
|
-
|
203
|
+
ran_successfully = asyncio.run(get_latest_deals(config))
|
221
204
|
if not ran_successfully:
|
222
|
-
# Gracefully exit on Exception raised by get_latest_deals
|
223
205
|
return
|
224
|
-
|
225
206
|
else:
|
226
207
|
echo_info(dedent("""
|
227
208
|
To prevent abuse lastest deals can only be pulled every 8 hours.
|
@@ -230,7 +211,7 @@ def latest_deals():
|
|
230
211
|
config.run_time = last_ran
|
231
212
|
|
232
213
|
if books := get_deals_found_at(config.run_time):
|
233
|
-
print_books(books)
|
214
|
+
print_books(config, books)
|
234
215
|
else:
|
235
216
|
echo_info("No new deals found.")
|
236
217
|
|
@@ -238,11 +219,17 @@ def latest_deals():
|
|
238
219
|
@cli.command()
|
239
220
|
def active_deals():
|
240
221
|
"""Get all active deals."""
|
222
|
+
try:
|
223
|
+
config = Config.load()
|
224
|
+
except FileNotFoundError:
|
225
|
+
config = _set_config()
|
226
|
+
|
241
227
|
if books := get_active_deals():
|
242
|
-
print_books(books)
|
228
|
+
print_books(config, books)
|
243
229
|
else:
|
244
230
|
echo_info("No deals found.")
|
245
231
|
|
246
232
|
|
247
233
|
if __name__ == '__main__':
|
234
|
+
os.environ.setdefault("ENTRYPOINT", "CLI")
|
248
235
|
cli()
|
tbr_deal_finder/config.py
CHANGED
@@ -3,9 +3,9 @@ from dataclasses import dataclass
|
|
3
3
|
from datetime import datetime
|
4
4
|
from typing import Union
|
5
5
|
|
6
|
-
from tbr_deal_finder import
|
6
|
+
from tbr_deal_finder.utils import get_data_dir
|
7
7
|
|
8
|
-
_CONFIG_PATH =
|
8
|
+
_CONFIG_PATH = get_data_dir().joinpath("config.ini")
|
9
9
|
|
10
10
|
_LOCALE_CURRENCY_MAP = {
|
11
11
|
"us": "$",
|
@@ -26,7 +26,7 @@ class Config:
|
|
26
26
|
library_export_paths: list[str]
|
27
27
|
tracked_retailers: list[str]
|
28
28
|
max_price: float = 8.0
|
29
|
-
min_discount: int =
|
29
|
+
min_discount: int = 30
|
30
30
|
run_time: datetime = datetime.now()
|
31
31
|
|
32
32
|
locale: str = "us" # This will be set as a class attribute below
|
@@ -0,0 +1,147 @@
|
|
1
|
+
"""
|
2
|
+
Desktop application update checker and handler.
|
3
|
+
For packaged desktop applications (.dmg/.exe).
|
4
|
+
"""
|
5
|
+
import json
|
6
|
+
import logging
|
7
|
+
import os
|
8
|
+
import platform
|
9
|
+
import subprocess
|
10
|
+
import tempfile
|
11
|
+
import webbrowser
|
12
|
+
from pathlib import Path
|
13
|
+
from typing import Optional, Dict, Any
|
14
|
+
|
15
|
+
import requests
|
16
|
+
from packaging import version
|
17
|
+
|
18
|
+
from tbr_deal_finder import __VERSION__
|
19
|
+
|
20
|
+
logger = logging.getLogger(__name__)
|
21
|
+
|
22
|
+
class DesktopUpdater:
|
23
|
+
"""Handle updates for packaged desktop applications."""
|
24
|
+
|
25
|
+
def __init__(self, github_repo: str = "WillNye/tbr-deal-finder"):
|
26
|
+
self.github_repo = github_repo
|
27
|
+
self.current_version = __VERSION__
|
28
|
+
self.platform = platform.system().lower()
|
29
|
+
|
30
|
+
def check_for_updates(self) -> Optional[Dict[str, Any]]:
|
31
|
+
"""
|
32
|
+
Check GitHub releases for newer versions.
|
33
|
+
Returns dict with update info or None if no update available.
|
34
|
+
"""
|
35
|
+
try:
|
36
|
+
# Check GitHub releases API
|
37
|
+
response = requests.get(
|
38
|
+
f"https://api.github.com/repos/{self.github_repo}/releases/latest",
|
39
|
+
timeout=5
|
40
|
+
)
|
41
|
+
response.raise_for_status()
|
42
|
+
|
43
|
+
release_data = response.json()
|
44
|
+
latest_version = release_data["tag_name"].lstrip("v")
|
45
|
+
if version.parse(latest_version) > version.parse(self.current_version):
|
46
|
+
release_url = release_data["html_url"]
|
47
|
+
return {
|
48
|
+
"version": latest_version,
|
49
|
+
"download_url": release_url,
|
50
|
+
"release_notes": release_data.get("body", ""),
|
51
|
+
"release_url": release_url
|
52
|
+
}
|
53
|
+
|
54
|
+
return None
|
55
|
+
|
56
|
+
except Exception as e:
|
57
|
+
logger.error(f"Failed to check updates for {self.github_repo}: {e}")
|
58
|
+
return None
|
59
|
+
|
60
|
+
def download_update(self, download_url: str, progress_callback=None) -> Optional[Path]:
|
61
|
+
"""
|
62
|
+
Download the update file.
|
63
|
+
Returns path to downloaded file or None if failed.
|
64
|
+
"""
|
65
|
+
try:
|
66
|
+
response = requests.get(download_url, stream=True, timeout=30)
|
67
|
+
response.raise_for_status()
|
68
|
+
|
69
|
+
# Determine file extension
|
70
|
+
filename = download_url.split("/")[-1]
|
71
|
+
temp_file = Path(tempfile.gettempdir()) / filename
|
72
|
+
|
73
|
+
total_size = int(response.headers.get('content-length', 0))
|
74
|
+
downloaded = 0
|
75
|
+
|
76
|
+
with open(temp_file, 'wb') as f:
|
77
|
+
for chunk in response.iter_content(chunk_size=8192):
|
78
|
+
if chunk:
|
79
|
+
f.write(chunk)
|
80
|
+
downloaded += len(chunk)
|
81
|
+
|
82
|
+
if progress_callback and total_size > 0:
|
83
|
+
progress = (downloaded / total_size) * 100
|
84
|
+
progress_callback(progress)
|
85
|
+
|
86
|
+
return temp_file
|
87
|
+
|
88
|
+
except Exception as e:
|
89
|
+
logger.error(f"Failed to download update: {e}")
|
90
|
+
return None
|
91
|
+
|
92
|
+
def install_update(self, update_file: Path) -> bool:
|
93
|
+
"""
|
94
|
+
Install the downloaded update.
|
95
|
+
Platform-specific installation logic.
|
96
|
+
"""
|
97
|
+
if self.platform == "darwin":
|
98
|
+
return self._install_macos_update(update_file)
|
99
|
+
elif self.platform == "windows":
|
100
|
+
return self._install_windows_update(update_file)
|
101
|
+
elif self.platform == "linux":
|
102
|
+
return self._install_linux_update(update_file)
|
103
|
+
else:
|
104
|
+
return False
|
105
|
+
|
106
|
+
def _install_macos_update(self, dmg_file: Path) -> bool:
|
107
|
+
"""Install .dmg update on macOS."""
|
108
|
+
try:
|
109
|
+
# Open the DMG file - user will need to drag to Applications
|
110
|
+
subprocess.run(["open", str(dmg_file)], check=True)
|
111
|
+
return True
|
112
|
+
except subprocess.CalledProcessError:
|
113
|
+
return False
|
114
|
+
|
115
|
+
def _install_windows_update(self, exe_file: Path) -> bool:
|
116
|
+
"""Install .exe update on Windows."""
|
117
|
+
try:
|
118
|
+
# Run the installer
|
119
|
+
subprocess.run([str(exe_file)], check=True)
|
120
|
+
return True
|
121
|
+
except subprocess.CalledProcessError:
|
122
|
+
return False
|
123
|
+
|
124
|
+
def _install_linux_update(self, appimage_file: Path) -> bool:
|
125
|
+
"""Install AppImage update on Linux."""
|
126
|
+
try:
|
127
|
+
# Make executable and offer to replace current installation
|
128
|
+
os.chmod(appimage_file, 0o755)
|
129
|
+
|
130
|
+
# For AppImage, we'd typically replace the current file
|
131
|
+
# This is more complex and might require user permission
|
132
|
+
return True
|
133
|
+
except Exception:
|
134
|
+
return False
|
135
|
+
|
136
|
+
def open_download_page(self, release_url: str):
|
137
|
+
"""Open the GitHub release page in browser."""
|
138
|
+
webbrowser.open(release_url)
|
139
|
+
|
140
|
+
|
141
|
+
# Global instance
|
142
|
+
desktop_updater = DesktopUpdater()
|
143
|
+
|
144
|
+
|
145
|
+
def check_for_desktop_updates() -> Optional[Dict[str, Any]]:
|
146
|
+
"""Convenience function to check for updates."""
|
147
|
+
return desktop_updater.check_for_updates()
|
File without changes
|