tbr-deal-finder 0.2.1__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.
Files changed (32) hide show
  1. tbr_deal_finder/__init__.py +1 -5
  2. tbr_deal_finder/__main__.py +7 -0
  3. tbr_deal_finder/book.py +16 -8
  4. tbr_deal_finder/cli.py +13 -27
  5. tbr_deal_finder/config.py +2 -2
  6. tbr_deal_finder/desktop_updater.py +147 -0
  7. tbr_deal_finder/gui/__init__.py +0 -0
  8. tbr_deal_finder/gui/main.py +725 -0
  9. tbr_deal_finder/gui/pages/__init__.py +1 -0
  10. tbr_deal_finder/gui/pages/all_books.py +93 -0
  11. tbr_deal_finder/gui/pages/all_deals.py +63 -0
  12. tbr_deal_finder/gui/pages/base_book_page.py +291 -0
  13. tbr_deal_finder/gui/pages/book_details.py +604 -0
  14. tbr_deal_finder/gui/pages/latest_deals.py +370 -0
  15. tbr_deal_finder/gui/pages/settings.py +389 -0
  16. tbr_deal_finder/retailer/amazon.py +58 -7
  17. tbr_deal_finder/retailer/amazon_custom_auth.py +79 -0
  18. tbr_deal_finder/retailer/audible.py +2 -1
  19. tbr_deal_finder/retailer/chirp.py +55 -11
  20. tbr_deal_finder/retailer/kindle.py +31 -19
  21. tbr_deal_finder/retailer/librofm.py +53 -20
  22. tbr_deal_finder/retailer/models.py +31 -1
  23. tbr_deal_finder/retailer_deal.py +38 -14
  24. tbr_deal_finder/tracked_books.py +24 -18
  25. tbr_deal_finder/utils.py +64 -2
  26. tbr_deal_finder/version_check.py +40 -0
  27. {tbr_deal_finder-0.2.1.dist-info → tbr_deal_finder-0.3.1.dist-info}/METADATA +18 -87
  28. tbr_deal_finder-0.3.1.dist-info/RECORD +38 -0
  29. {tbr_deal_finder-0.2.1.dist-info → tbr_deal_finder-0.3.1.dist-info}/entry_points.txt +1 -0
  30. tbr_deal_finder-0.2.1.dist-info/RECORD +0 -25
  31. {tbr_deal_finder-0.2.1.dist-info → tbr_deal_finder-0.3.1.dist-info}/WHEEL +0 -0
  32. {tbr_deal_finder-0.2.1.dist-info → tbr_deal_finder-0.3.1.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,370 @@
1
+ import flet as ft
2
+ from datetime import datetime, timedelta
3
+ from typing import List
4
+
5
+ from tbr_deal_finder.book import get_deals_found_at, Book, BookFormat, is_qualifying_deal
6
+ from tbr_deal_finder.utils import get_duckdb_conn, get_latest_deal_last_ran
7
+ from tbr_deal_finder.gui.pages.base_book_page import BaseBookPage
8
+
9
+
10
+ class LatestDealsPage(BaseBookPage):
11
+ def __init__(self, app):
12
+ super().__init__(app, 4)
13
+ self.last_run_time = None
14
+
15
+ def get_page_title(self) -> str:
16
+ return "Latest Deals"
17
+
18
+ def get_empty_state_message(self) -> tuple[str, str]:
19
+ return (
20
+ "No recent deals found",
21
+ "Click 'Get Latest Deals' to check for new deals"
22
+ )
23
+
24
+ def should_include_refresh_button(self) -> bool:
25
+ """Latest deals doesn't use normal refresh button"""
26
+ return False
27
+
28
+ def build(self):
29
+ """Build the latest deals page with custom header"""
30
+ self.check_last_run()
31
+
32
+ # Custom header with run button
33
+ header = self.build_header()
34
+
35
+ # Progress indicator (hidden by default)
36
+ self.progress_container = ft.Container(
37
+ content=ft.Column([
38
+ ft.ProgressBar(),
39
+ ft.Text("Checking for latest deals...", text_align=ft.TextAlign.CENTER)
40
+ ], spacing=10),
41
+ visible=False,
42
+ padding=20
43
+ )
44
+
45
+ # Standard search controls (but without refresh button)
46
+ search_controls = self.build_search_controls()
47
+
48
+ # Loading indicator for normal operations
49
+ self.loading_container = ft.Container(
50
+ content=ft.Column([
51
+ ft.ProgressRing(),
52
+ ft.Text("Loading...", text_align=ft.TextAlign.CENTER)
53
+ ], spacing=10, horizontal_alignment=ft.CrossAxisAlignment.CENTER),
54
+ visible=self.is_loading,
55
+ alignment=ft.alignment.center,
56
+ height=200
57
+ )
58
+
59
+ self.load_items()
60
+
61
+ # Results section
62
+ results = self.build_results()
63
+
64
+ return ft.Column([
65
+ ft.Text(self.get_page_title(), size=24, weight=ft.FontWeight.BOLD),
66
+ header,
67
+ self.progress_container,
68
+ search_controls,
69
+ self.loading_container,
70
+ results
71
+ ], spacing=20, scroll=ft.ScrollMode.AUTO)
72
+
73
+ def build_header(self):
74
+ """Build the header with run button and status"""
75
+ can_run = self.can_run_latest_deals()
76
+
77
+ if not can_run and self.last_run_time:
78
+ next_run_time = self.last_run_time + timedelta(hours=8)
79
+ time_remaining = next_run_time - datetime.now()
80
+ hours_remaining = max(0, int(time_remaining.total_seconds() / 3600))
81
+ status_text = f"Next run available in {hours_remaining} hours"
82
+ status_color = ft.Colors.ORANGE
83
+ elif self.last_run_time:
84
+ status_text = f"Last run: {self.last_run_time.strftime('%Y-%m-%d %H:%M')}"
85
+ status_color = ft.Colors.GREEN
86
+ else:
87
+ status_text = "No previous runs"
88
+ status_color = ft.Colors.GREY_600
89
+
90
+ run_button = ft.ElevatedButton(
91
+ "Get Latest Deals",
92
+ icon=ft.Icons.SYNC,
93
+ on_click=self.run_latest_deals,
94
+ disabled=not can_run or self.is_loading
95
+ )
96
+
97
+ info_button = ft.IconButton(
98
+ icon=ft.Icons.INFO_OUTLINE,
99
+ tooltip="Latest deals can only be run every 8 hours to prevent abuse",
100
+ on_click=self.show_info_dialog
101
+ )
102
+
103
+ return ft.Container(
104
+ content=ft.Column([
105
+ ft.Row([
106
+ run_button,
107
+ info_button
108
+ ], alignment=ft.MainAxisAlignment.START),
109
+ ft.Text(status_text, color=status_color, size=14)
110
+ ], spacing=10),
111
+ padding=20,
112
+ border=ft.border.all(1, ft.Colors.OUTLINE),
113
+ border_radius=8
114
+ )
115
+
116
+ def build_results(self):
117
+ """Build the results section using base class pagination"""
118
+ # Items list container that we can update without rebuilding search controls
119
+ self.items_container = ft.Container()
120
+ self.pagination_container = ft.Container()
121
+
122
+ # Initial build of items and pagination
123
+ self.update_items_display()
124
+
125
+ return ft.Column([
126
+ self.items_container,
127
+ self.pagination_container
128
+ ], spacing=20)
129
+
130
+ def build_items_list(self):
131
+ """Override base class to handle format grouping with proper pagination"""
132
+ if self.is_loading:
133
+ return ft.Container()
134
+
135
+ if not self.filtered_items:
136
+ main_msg, sub_msg = self.get_empty_state_message()
137
+ return ft.Container(
138
+ content=ft.Column([
139
+ ft.Icon(ft.Icons.SEARCH, size=64, color=ft.Colors.GREY_400),
140
+ ft.Text(main_msg, size=18, color=ft.Colors.GREY_600),
141
+ ft.Text(sub_msg, color=ft.Colors.GREY_500)
142
+ ], alignment=ft.MainAxisAlignment.CENTER, horizontal_alignment=ft.CrossAxisAlignment.CENTER),
143
+ alignment=ft.alignment.center,
144
+ height=300
145
+ )
146
+
147
+ # Apply pagination to all filtered items
148
+ start_idx = self.current_page * self.items_per_page
149
+ end_idx = min(start_idx + self.items_per_page, len(self.filtered_items))
150
+ page_items = self.filtered_items[start_idx:end_idx]
151
+
152
+ # Group page items by format for better organization
153
+ ebooks = [deal for deal in page_items if deal.format == BookFormat.EBOOK]
154
+ audiobooks = [deal for deal in page_items if deal.format == BookFormat.AUDIOBOOK]
155
+
156
+ sections = []
157
+
158
+ if ebooks:
159
+ sections.append(self.build_format_section("E-Book Deals", ebooks))
160
+
161
+ if audiobooks:
162
+ sections.append(self.build_format_section("Audiobook Deals", audiobooks))
163
+
164
+ if not sections:
165
+ return ft.Container()
166
+
167
+ return ft.Container(
168
+ content=ft.Column(sections, spacing=20),
169
+ padding=10
170
+ )
171
+
172
+ def build_format_section(self, title: str, deals: List[Book]):
173
+ """Build a section for a specific format (without pagination - that's handled at the page level)"""
174
+ deals_tiles = []
175
+ current_title_id = None
176
+ for deal in deals:
177
+ # Add spacing between different books
178
+ if current_title_id and current_title_id != deal.title_id:
179
+ deals_tiles.append(ft.Divider())
180
+ current_title_id = deal.title_id
181
+
182
+ tile = self.create_item_tile(deal)
183
+ deals_tiles.append(tile)
184
+
185
+ return ft.Container(
186
+ content=ft.Column([
187
+ ft.Text(title, size=18, weight=ft.FontWeight.BOLD),
188
+ ft.Column(deals_tiles, spacing=5)
189
+ ], spacing=10),
190
+ padding=15,
191
+ border=ft.border.all(1, ft.Colors.OUTLINE),
192
+ border_radius=8
193
+ )
194
+
195
+ def load_items(self):
196
+ """Load deals found at the last run time"""
197
+ if self.last_run_time:
198
+ try:
199
+ self.items = [
200
+ book
201
+ for book in get_deals_found_at(self.last_run_time)
202
+ if is_qualifying_deal(self.app.config, book)
203
+ ]
204
+ self.apply_filters()
205
+ except Exception as e:
206
+ self.items = []
207
+ self.filtered_items = []
208
+ print(f"Error loading latest deals: {e}")
209
+ else:
210
+ self.items = []
211
+ self.filtered_items = []
212
+
213
+ def create_item_tile(self, deal: Book):
214
+ """Create a tile for a single deal"""
215
+ # Truncate title if too long
216
+ title = deal.title
217
+ if len(title) > 50:
218
+ title = f"{title[:50]}..."
219
+
220
+ # Format price and discount
221
+ price_text = f"{deal.current_price_string()} ({deal.discount()}% off)"
222
+
223
+ return ft.Card(
224
+ content=ft.Container(
225
+ content=ft.ListTile(
226
+ title=ft.Text(title, weight=ft.FontWeight.BOLD),
227
+ subtitle=ft.Column([
228
+ ft.Text(f"by {deal.authors}", color=ft.Colors.GREY_600),
229
+ ft.Text(price_text, color=ft.Colors.GREEN, weight=ft.FontWeight.BOLD)
230
+ ], spacing=2),
231
+ trailing=ft.Column([
232
+ ft.Text(deal.retailer, weight=ft.FontWeight.BOLD, size=12)
233
+ ], alignment=ft.MainAxisAlignment.CENTER),
234
+ on_click=lambda e, book=deal: self.app.show_book_details(book, format_type=book.format)
235
+ ),
236
+ padding=5
237
+ )
238
+ )
239
+
240
+ def check_last_run(self):
241
+ """Check when deals were last run"""
242
+ if not self.last_run_time:
243
+ db_conn = get_duckdb_conn()
244
+ self.last_run_time = get_latest_deal_last_ran(db_conn)
245
+
246
+ def can_run_latest_deals(self) -> bool:
247
+ """Check if latest deals can be run (8 hour cooldown)"""
248
+ if not self.last_run_time:
249
+ return True
250
+
251
+ min_age = datetime.now() - timedelta(hours=8)
252
+ return self.last_run_time < min_age
253
+
254
+ def run_latest_deals(self, e):
255
+ """Run the latest deals check"""
256
+ if not self.app.config:
257
+ self.show_error("Please configure settings first")
258
+ return
259
+
260
+ if not self.can_run_latest_deals():
261
+ self.show_error("Latest deals can only be run every 8 hours")
262
+ return
263
+
264
+ # Store the button reference for later re-enabling
265
+ self.run_button = e.control
266
+
267
+ # Use Flet's proper async task runner instead of threading
268
+ self.app.page.run_task(self._run_async_latest_deals)
269
+
270
+ async def _run_async_latest_deals(self):
271
+ """Run the async latest deals operation using Flet's async support"""
272
+ # Set loading state
273
+ self.is_loading = True
274
+ self.progress_container.visible = True
275
+ self.run_button.disabled = True
276
+
277
+ # Update the page to show loading state
278
+ self.app.page.update()
279
+
280
+ try:
281
+ # Run the async operation directly (no need for new event loop)
282
+ success = await self.app.run_latest_deals()
283
+
284
+ if success:
285
+ # Update the run time and load new deals
286
+ self.last_run_time = self.app.config.run_time
287
+ self.load_items()
288
+ self.show_success(f"Found {len(self.items)} new deals!")
289
+ else:
290
+ self.show_error("Failed to get latest deals. Please check your configuration.")
291
+
292
+ except Exception as ex:
293
+ self.show_error(f"Error getting latest deals: {str(ex)}")
294
+
295
+ finally:
296
+ # Reset loading state
297
+ self.is_loading = False
298
+ self.progress_container.visible = False
299
+ self.run_button.disabled = False
300
+ self.check_last_run() # Refresh the status
301
+
302
+ # Update the page to reset loading state
303
+ self.app.page.update()
304
+
305
+ # Refresh all pages since latest deals affects data on other pages
306
+ self.app.refresh_all_pages()
307
+ # Force a full page rebuild to update header status and content
308
+ self.app.update_content()
309
+
310
+
311
+ def show_info_dialog(self, e):
312
+ """Show information about the latest deals feature"""
313
+ dlg = ft.AlertDialog(
314
+ title=ft.Text("Latest Deals Information"),
315
+ content=ft.Text(
316
+ "The latest deals feature checks all tracked retailers for new deals on books in your library.\n\n"
317
+ "To prevent abuse of retailer APIs, this feature can only be run every 8 hours.\n\n"
318
+ "If you need to see existing deals immediately, use the 'All Deals' page instead."
319
+ ),
320
+ actions=[ft.TextButton("OK", on_click=lambda e: self.close_dialog(dlg))]
321
+ )
322
+ self.app.page.overlay.append(dlg)
323
+ dlg.open = True
324
+ self.app.page.update()
325
+
326
+ def show_error(self, message: str):
327
+ """Show error dialog"""
328
+ dlg = ft.AlertDialog(
329
+ title=ft.Text("Error"),
330
+ content=ft.Text(message),
331
+ actions=[ft.TextButton("OK", on_click=lambda e: self.close_dialog(dlg))]
332
+ )
333
+ self.app.page.overlay.append(dlg)
334
+ dlg.open = True
335
+ self.app.page.update()
336
+
337
+ def show_success(self, message: str):
338
+ """Show success dialog"""
339
+ dlg = ft.AlertDialog(
340
+ title=ft.Text("Success"),
341
+ content=ft.Text(message),
342
+ actions=[ft.TextButton("OK", on_click=lambda e: self.close_dialog(dlg))]
343
+ )
344
+ self.app.page.overlay.append(dlg)
345
+ dlg.open = True
346
+ self.app.page.update()
347
+
348
+ def close_dialog(self, dialog):
349
+ """Close dialog"""
350
+ dialog.open = False
351
+ self.app.page.update()
352
+
353
+ def refresh_page_state(self):
354
+ """Override base refresh to handle latest deals specific state"""
355
+ # Reset state
356
+ self.items = []
357
+ self.filtered_items = []
358
+ self.current_page = 0
359
+ self.search_query = ""
360
+ self.format_filter = "All"
361
+
362
+ # Reset UI elements if they exist
363
+ if hasattr(self, 'search_field'):
364
+ self.search_field.value = ""
365
+ if hasattr(self, 'format_dropdown'):
366
+ self.format_dropdown.value = "All"
367
+
368
+ # Check last run and reload data
369
+ self.check_last_run()
370
+ self.load_items()