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.
Files changed (34) hide show
  1. tbr_deal_finder/__init__.py +1 -5
  2. tbr_deal_finder/__main__.py +7 -0
  3. tbr_deal_finder/book.py +28 -8
  4. tbr_deal_finder/cli.py +15 -28
  5. tbr_deal_finder/config.py +3 -3
  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/migrations.py +26 -0
  17. tbr_deal_finder/queries/latest_unknown_book_sync.sql +5 -0
  18. tbr_deal_finder/retailer/amazon.py +60 -9
  19. tbr_deal_finder/retailer/amazon_custom_auth.py +79 -0
  20. tbr_deal_finder/retailer/audible.py +2 -1
  21. tbr_deal_finder/retailer/chirp.py +55 -11
  22. tbr_deal_finder/retailer/kindle.py +68 -44
  23. tbr_deal_finder/retailer/librofm.py +58 -21
  24. tbr_deal_finder/retailer/models.py +31 -1
  25. tbr_deal_finder/retailer_deal.py +62 -21
  26. tbr_deal_finder/tracked_books.py +76 -8
  27. tbr_deal_finder/utils.py +64 -2
  28. tbr_deal_finder/version_check.py +40 -0
  29. {tbr_deal_finder-0.2.0.dist-info → tbr_deal_finder-0.3.1.dist-info}/METADATA +19 -88
  30. tbr_deal_finder-0.3.1.dist-info/RECORD +38 -0
  31. {tbr_deal_finder-0.2.0.dist-info → tbr_deal_finder-0.3.1.dist-info}/entry_points.txt +1 -0
  32. tbr_deal_finder-0.2.0.dist-info/RECORD +0 -24
  33. {tbr_deal_finder-0.2.0.dist-info → tbr_deal_finder-0.3.1.dist-info}/WHEEL +0 -0
  34. {tbr_deal_finder-0.2.0.dist-info → tbr_deal_finder-0.3.1.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,389 @@
1
+ from pathlib import Path
2
+
3
+ import flet as ft
4
+
5
+ from tbr_deal_finder.config import Config
6
+ from tbr_deal_finder.retailer import RETAILER_MAP
7
+ from tbr_deal_finder.tracked_books import reprocess_incomplete_tbr_books, clear_unknown_books
8
+
9
+
10
+ class SettingsPage:
11
+ def __init__(self, app):
12
+ self.app = app
13
+ self.config = None
14
+ self.library_paths = []
15
+ self.tracked_retailers = []
16
+ self.max_price = 8.0
17
+ self.min_discount = 30
18
+ self.locale = "us"
19
+
20
+ # Create file picker once and add to page overlay
21
+ self.file_picker = ft.FilePicker(on_result=self.on_file_picker_result)
22
+
23
+ self.load_current_config()
24
+
25
+ def load_current_config(self):
26
+ """Load current configuration or set defaults"""
27
+ try:
28
+ self.config = Config.load()
29
+ self.library_paths = self.config.library_export_paths.copy()
30
+ self.tracked_retailers = self.config.tracked_retailers.copy()
31
+ self.max_price = self.config.max_price
32
+ self.min_discount = self.config.min_discount
33
+ self.locale = self.config.locale
34
+ except FileNotFoundError:
35
+ self.library_paths = []
36
+ self.tracked_retailers = list(RETAILER_MAP.keys())
37
+
38
+ def build(self):
39
+ """Build the settings page content"""
40
+
41
+ # Add file picker to page overlay if not already added
42
+ if self.file_picker not in self.app.page.overlay:
43
+ self.app.page.overlay.append(self.file_picker)
44
+ self.app.page.update() # Important: update page after adding to overlay
45
+
46
+ # Library Export Paths Section
47
+ self.library_paths_list = ft.ListView(
48
+ height=150,
49
+ spacing=5
50
+ )
51
+ self.update_library_paths_list()
52
+
53
+ library_section = ft.Container(
54
+ content=ft.Column([
55
+ ft.Text("Library Export Paths", size=18, weight=ft.FontWeight.BOLD),
56
+ ft.Text("Add your StoryGraph or Goodreads export files", color=ft.Colors.GREY_600),
57
+ self.library_paths_list,
58
+ ft.Row([
59
+ ft.ElevatedButton(
60
+ "Browse Files",
61
+ icon=ft.Icons.FOLDER_OPEN,
62
+ on_click=self.add_library_path
63
+ ),
64
+ ft.ElevatedButton(
65
+ "Enter Path Manually",
66
+ icon=ft.Icons.EDIT,
67
+ on_click=lambda e: self.show_text_input_dialog()
68
+ ),
69
+ ft.OutlinedButton(
70
+ "Remove Selected",
71
+ icon=ft.Icons.REMOVE,
72
+ on_click=self.remove_library_path
73
+ )
74
+ ], wrap=True)
75
+ ], spacing=10),
76
+ padding=20,
77
+ border=ft.border.all(1, ft.Colors.OUTLINE),
78
+ border_radius=8
79
+ )
80
+
81
+ # Tracked Retailers Section
82
+ retailer_checkboxes = []
83
+ for retailer in RETAILER_MAP.keys():
84
+ checkbox = ft.Checkbox(
85
+ label=retailer,
86
+ value=retailer in self.tracked_retailers,
87
+ on_change=lambda e, r=retailer: self.toggle_retailer(r, e.control.value)
88
+ )
89
+ retailer_checkboxes.append(checkbox)
90
+
91
+ retailers_section = ft.Container(
92
+ content=ft.Column([
93
+ ft.Text("Tracked Retailers", size=18, weight=ft.FontWeight.BOLD),
94
+ ft.Text("Select retailers to check for deals", color=ft.Colors.GREY_600),
95
+ ft.Column(retailer_checkboxes, spacing=5)
96
+ ], spacing=10),
97
+ padding=20,
98
+ border=ft.border.all(1, ft.Colors.OUTLINE),
99
+ border_radius=8
100
+ )
101
+
102
+ # Locale Selection
103
+ locale_options = {
104
+ "US and all other countries not listed": "us",
105
+ "Canada": "ca",
106
+ "UK and Ireland": "uk",
107
+ "Australia and New Zealand": "au",
108
+ "France, Belgium, Switzerland": "fr",
109
+ "Germany, Austria, Switzerland": "de",
110
+ "Japan": "jp",
111
+ "Italy": "it",
112
+ "India": "in",
113
+ "Spain": "es",
114
+ "Brazil": "br"
115
+ }
116
+
117
+ current_locale_name = [k for k, v in locale_options.items() if v == self.locale][0]
118
+
119
+ self.locale_dropdown = ft.Dropdown(
120
+ value=current_locale_name,
121
+ options=[ft.dropdown.Option(k) for k in locale_options.keys()],
122
+ on_change=lambda e: setattr(self, 'locale', locale_options[e.control.value])
123
+ )
124
+
125
+ # Price and Discount Settings
126
+ self.max_price_field = ft.TextField(
127
+ label="Maximum Price",
128
+ value=str(self.max_price),
129
+ keyboard_type=ft.KeyboardType.NUMBER,
130
+ on_change=self.update_max_price
131
+ )
132
+
133
+ self.min_discount_field = ft.TextField(
134
+ label="Minimum Discount %",
135
+ value=str(self.min_discount),
136
+ keyboard_type=ft.KeyboardType.NUMBER,
137
+ on_change=self.update_min_discount
138
+ )
139
+
140
+ price_section = ft.Container(
141
+ content=ft.Column([
142
+ ft.Text("Deal Criteria", size=18, weight=ft.FontWeight.BOLD),
143
+ ft.Row([
144
+ self.max_price_field,
145
+ self.min_discount_field
146
+ ], spacing=20),
147
+ ft.Text("Locale", size=16, weight=ft.FontWeight.BOLD),
148
+ self.locale_dropdown
149
+ ], spacing=10),
150
+ padding=20,
151
+ border=ft.border.all(1, ft.Colors.OUTLINE),
152
+ border_radius=8
153
+ )
154
+
155
+ # Save and Cancel buttons
156
+ button_row = ft.Row([
157
+ ft.ElevatedButton(
158
+ "Save Configuration",
159
+ icon=ft.Icons.SAVE,
160
+ on_click=self.save_config
161
+ ),
162
+ ft.OutlinedButton(
163
+ "Cancel",
164
+ on_click=self.cancel_changes
165
+ )
166
+ ], spacing=10)
167
+
168
+ # Update check section
169
+ update_section = ft.Container(
170
+ content=ft.Column([
171
+ ft.Text("Application Updates", size=16, weight=ft.FontWeight.BOLD),
172
+ ft.Text("Check for the latest version", color=ft.Colors.GREY_600),
173
+ ft.ElevatedButton(
174
+ "Check for Updates",
175
+ icon=ft.Icons.SYSTEM_UPDATE,
176
+ on_click=lambda e: self.app.check_for_updates_button(),
177
+ style=ft.ButtonStyle(bgcolor=ft.Colors.BLUE_600),
178
+ )
179
+ ], spacing=10),
180
+ padding=20,
181
+ )
182
+
183
+ # Main settings content
184
+ main_content = ft.ListView([
185
+ ft.Text("Settings", size=24, weight=ft.FontWeight.BOLD),
186
+ library_section,
187
+ retailers_section,
188
+ price_section,
189
+ button_row
190
+ ], spacing=20, padding=ft.padding.all(20), expand=True)
191
+
192
+ # Create layout with main content on left and update section on right
193
+ return ft.Row([
194
+ main_content,
195
+ ft.Container(
196
+ content=update_section,
197
+ width=300,
198
+ alignment=ft.alignment.top_left
199
+ )
200
+ ], spacing=20, expand=True)
201
+
202
+ def update_library_paths_list(self):
203
+ """Update the library paths list view"""
204
+ self.library_paths_list.controls.clear()
205
+ for i, path in enumerate(self.library_paths):
206
+ item = ft.ListTile(
207
+ title=ft.Text(path),
208
+ trailing=ft.Checkbox(value=False),
209
+ dense=True
210
+ )
211
+ self.library_paths_list.controls.append(item)
212
+ self.app.page.update()
213
+
214
+ def add_library_path(self, e):
215
+ """Add a new library export path"""
216
+ def on_result(e: ft.FilePickerResultEvent):
217
+ if e.files and len(e.files) > 0:
218
+ new_path = e.files[0].path
219
+ if new_path not in self.library_paths:
220
+ self.library_paths.append(new_path)
221
+ self.update_library_paths_list()
222
+
223
+ # Create a file picker for this operation
224
+ file_picker = ft.FilePicker(on_result=on_result)
225
+
226
+ # Add to overlay
227
+ self.app.page.overlay.append(file_picker)
228
+ self.app.page.update()
229
+
230
+ # Open the file picker
231
+ file_picker.pick_files(
232
+ dialog_title="Select Library Export File",
233
+ file_type=ft.FilePickerFileType.CUSTOM,
234
+ initial_directory=str(Path.home()),
235
+ allowed_extensions=["csv"]
236
+ )
237
+
238
+ def on_file_picker_result(self, e: ft.FilePickerResultEvent):
239
+ """Handle file picker result (legacy method - not used with new implementation)"""
240
+ if e.files:
241
+ for f in e.files:
242
+ new_path = f.path
243
+ if new_path not in self.library_paths:
244
+ self.library_paths.append(new_path)
245
+ self.update_library_paths_list()
246
+
247
+ def remove_library_path(self, e):
248
+ """Remove selected library paths"""
249
+ to_remove = []
250
+ for i, control in enumerate(self.library_paths_list.controls):
251
+ if control.trailing.value: # If checkbox is checked
252
+ to_remove.append(i)
253
+
254
+ # Remove in reverse order to maintain indices
255
+ for i in reversed(to_remove):
256
+ self.library_paths.pop(i)
257
+
258
+ self.update_library_paths_list()
259
+
260
+ def toggle_retailer(self, retailer: str, is_checked: bool):
261
+ """Toggle retailer tracking"""
262
+ if is_checked and retailer not in self.tracked_retailers:
263
+ self.tracked_retailers.append(retailer)
264
+ elif not is_checked and retailer in self.tracked_retailers:
265
+ self.tracked_retailers.remove(retailer)
266
+
267
+ def update_max_price(self, e):
268
+ """Update max price value"""
269
+ try:
270
+ self.max_price = float(e.control.value)
271
+ except ValueError:
272
+ pass
273
+
274
+ def update_min_discount(self, e):
275
+ """Update min discount value"""
276
+ try:
277
+ self.min_discount = int(e.control.value)
278
+ except ValueError:
279
+ pass
280
+
281
+ def save_config(self, e):
282
+ """Save the configuration"""
283
+ if not self.tracked_retailers:
284
+ self.show_error("You must track at least one retailer.")
285
+ return
286
+
287
+ try:
288
+ if self.config:
289
+ # Update existing config
290
+ self.config.library_export_paths = self.library_paths
291
+ self.config.tracked_retailers = self.tracked_retailers
292
+ self.config.max_price = self.max_price
293
+ self.config.min_discount = self.min_discount
294
+ self.config.set_locale(self.locale)
295
+ else:
296
+ # Create new config
297
+ self.config = Config(
298
+ library_export_paths=self.library_paths,
299
+ tracked_retailers=self.tracked_retailers,
300
+ max_price=self.max_price,
301
+ min_discount=self.min_discount
302
+ )
303
+ self.config.set_locale(self.locale)
304
+
305
+ self.config.save()
306
+
307
+ # Reprocess books if retailers changed
308
+ reprocess_incomplete_tbr_books(self.config)
309
+ clear_unknown_books()
310
+
311
+ self.show_success("Configuration saved successfully!")
312
+ self.app.config_updated(self.config)
313
+
314
+ except Exception as ex:
315
+ self.show_error(f"Error saving configuration: {str(ex)}")
316
+
317
+ def cancel_changes(self, e):
318
+ """Cancel changes and return to main view"""
319
+ self.load_current_config()
320
+ if self.app.config:
321
+ self.app.current_page = "all_deals"
322
+ self.app.nav_rail.selected_index = 0
323
+ else:
324
+ self.app.current_page = "all_deals"
325
+ self.app.update_content()
326
+
327
+ def show_error(self, message: str):
328
+ """Show error dialog"""
329
+ dlg = ft.AlertDialog(
330
+ title=ft.Text("Error"),
331
+ content=ft.Text(message),
332
+ actions=[ft.TextButton("OK", on_click=lambda e: self.close_dialog(dlg))]
333
+ )
334
+ self.app.page.overlay.append(dlg)
335
+ dlg.open = True
336
+ self.app.page.update()
337
+
338
+ def show_success(self, message: str):
339
+ """Show success dialog"""
340
+ dlg = ft.AlertDialog(
341
+ title=ft.Text("Success"),
342
+ content=ft.Text(message),
343
+ actions=[ft.TextButton("OK", on_click=lambda e: self.close_dialog(dlg))]
344
+ )
345
+ self.app.page.overlay.append(dlg)
346
+ dlg.open = True
347
+ self.app.page.update()
348
+
349
+ def show_text_input_dialog(self):
350
+ """Fallback: Show text input dialog for manual path entry"""
351
+ self.path_input = ft.TextField(
352
+ label="Enter full path to CSV file",
353
+ hint_text="/path/to/your/export.csv",
354
+ expand=True
355
+ )
356
+
357
+ def add_manual_path(e):
358
+ path = self.path_input.value.strip()
359
+ if path and path not in self.library_paths:
360
+ import os
361
+ if os.path.exists(path) and path.endswith('.csv'):
362
+ self.library_paths.append(path)
363
+ self.update_library_paths_list()
364
+ self.close_dialog(path_dialog)
365
+ else:
366
+ self.show_error("File does not exist or is not a CSV file")
367
+ else:
368
+ self.show_error("Please enter a valid path")
369
+
370
+ path_dialog = ft.AlertDialog(
371
+ title=ft.Text("Enter File Path"),
372
+ content=ft.Column([
373
+ ft.Text("File picker not available. Please enter the full path to your library export CSV file:"),
374
+ self.path_input
375
+ ], tight=True),
376
+ actions=[
377
+ ft.TextButton("Cancel", on_click=lambda e: self.close_dialog(path_dialog)),
378
+ ft.ElevatedButton("Add Path", on_click=add_manual_path)
379
+ ]
380
+ )
381
+
382
+ self.app.page.overlay.append(path_dialog)
383
+ path_dialog.open = True
384
+ self.app.page.update()
385
+
386
+ def close_dialog(self, dialog):
387
+ """Close dialog"""
388
+ dialog.open = False
389
+ self.app.page.update()
@@ -62,6 +62,32 @@ _MIGRATIONS = [
62
62
  );
63
63
  """
64
64
  ),
65
+ TableMigration(
66
+ version=1,
67
+ table_name="unknown_book",
68
+ sql="""
69
+ CREATE TABLE unknown_book
70
+ (
71
+ retailer VARCHAR,
72
+ title VARCHAR,
73
+ authors VARCHAR,
74
+ format VARCHAR,
75
+ book_id VARCHAR
76
+ );
77
+ """
78
+ ),
79
+ TableMigration(
80
+ version=1,
81
+ table_name="unknown_book_run_history",
82
+ sql="""
83
+ CREATE TABLE unknown_book_run_history
84
+ (
85
+ timepoint TIMESTAMP_NS,
86
+ ran_successfully BOOLEAN,
87
+ details VARCHAR
88
+ );
89
+ """
90
+ ),
65
91
  ]
66
92
 
67
93
 
@@ -0,0 +1,5 @@
1
+ SELECT timepoint
2
+ FROM unknown_book_run_history
3
+ WHERE ran_successfully = TRUE
4
+ ORDER BY timepoint DESC
5
+ LIMIT 1
@@ -1,24 +1,30 @@
1
+ import logging
1
2
  import sys
2
3
  import os.path
4
+ from typing import Union
3
5
 
4
6
  import audible
5
7
  import click
6
8
  from audible.login import build_init_cookies
7
9
  from textwrap import dedent
8
10
 
11
+ from tbr_deal_finder.retailer.amazon_custom_auth import CustomAuthenticator
12
+ from tbr_deal_finder.utils import get_data_dir
13
+
9
14
  if sys.platform != 'win32':
10
15
  # Breaks Windows support but required for Mac
11
16
  # Untested on Linux
12
17
  import readline # type: ignore
13
18
 
14
- from tbr_deal_finder import TBR_DEALS_PATH
15
19
  from tbr_deal_finder.config import Config
16
- from tbr_deal_finder.retailer.models import Retailer
20
+ from tbr_deal_finder.retailer.models import Retailer, GuiAuthContext
21
+
22
+ AUTH_PATH = get_data_dir().joinpath("audible.json")
17
23
 
18
- _AUTH_PATH = TBR_DEALS_PATH.joinpath("audible.json")
24
+ logger = logging.getLogger(__name__)
19
25
 
20
26
 
21
- def login_url_callback(url: str) -> str:
27
+ def default_login_url_callback(url: str) -> str:
22
28
  """Helper function for login with external browsers."""
23
29
 
24
30
  try:
@@ -67,19 +73,64 @@ def login_url_callback(url: str) -> str:
67
73
 
68
74
 
69
75
  class Amazon(Retailer):
70
- _auth: audible.Authenticator = None
76
+ _auth: Union[audible.Authenticator, CustomAuthenticator] = None
71
77
  _client: audible.AsyncClient = None
72
78
 
79
+ def user_is_authed(self) -> bool:
80
+ if not os.path.exists(AUTH_PATH):
81
+ return False
82
+
83
+ self._auth = audible.Authenticator.from_file(AUTH_PATH)
84
+ self._client = audible.AsyncClient(auth=self._auth)
85
+ return True
86
+
73
87
  async def set_auth(self):
74
- if not os.path.exists(_AUTH_PATH):
88
+ if not self.user_is_authed():
75
89
  auth = audible.Authenticator.from_login_external(
76
90
  locale=Config.locale,
77
- login_url_callback=login_url_callback
91
+ login_url_callback=default_login_url_callback
78
92
  )
79
93
 
80
94
  # Save credentials to file
81
- auth.to_file(_AUTH_PATH)
95
+ auth.to_file(AUTH_PATH)
82
96
 
83
- self._auth = audible.Authenticator.from_file(_AUTH_PATH)
97
+ self._auth = audible.Authenticator.from_file(AUTH_PATH)
84
98
  self._client = audible.AsyncClient(auth=self._auth)
85
99
 
100
+ @property
101
+ def gui_auth_context(self) -> GuiAuthContext:
102
+ if not self._auth:
103
+ self._auth = CustomAuthenticator.from_locale(Config.locale)
104
+
105
+ return GuiAuthContext(
106
+ title="Login to Amazon (Audible/Kindle)",
107
+ fields=[
108
+ {"name": "login_link", "label": "Link", "type": "text"}
109
+ ],
110
+ message=dedent(
111
+ """
112
+ Please copy the following url and insert it into a web browser of your choice to log into Amazon.
113
+ Once you have logged in, please insert the copied url.
114
+ Note: your browser will show you an error page (Page not found). This is expected.
115
+ """
116
+ ),
117
+ user_copy_context=self._auth.oauth_url
118
+ )
119
+
120
+
121
+ async def gui_auth(self, form_data: dict) -> bool:
122
+ if not self._auth:
123
+ if self.user_is_authed():
124
+ return True
125
+ try:
126
+ self._auth.external_login(form_data["login_link"])
127
+ # Save credentials to file
128
+ self._auth.to_file(AUTH_PATH)
129
+
130
+ self._auth = audible.Authenticator.from_file(AUTH_PATH)
131
+ self._client = audible.AsyncClient(auth=self._auth)
132
+ return True
133
+ except Exception as e:
134
+ logger.info(e)
135
+ return False
136
+
@@ -0,0 +1,79 @@
1
+ import logging
2
+ from typing import Optional, Any, Union
3
+ from urllib.parse import parse_qs
4
+
5
+ import audible
6
+ import httpx
7
+ from audible.login import create_code_verifier, build_oauth_url
8
+ from audible.register import register as register_
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ def external_login(
14
+ response_url: str,
15
+ domain: str,
16
+ serial: str,
17
+ code_verifier: bytes,
18
+ ) -> dict[str, Any]:
19
+ response_url = httpx.URL(response_url)
20
+ parsed_url = parse_qs(response_url.query.decode())
21
+
22
+ authorization_code = parsed_url["openid.oa2.authorization_code"][0]
23
+
24
+ return {
25
+ "authorization_code": authorization_code,
26
+ "code_verifier": code_verifier,
27
+ "domain": domain,
28
+ "serial": serial
29
+ }
30
+
31
+
32
+ class CustomAuthenticator(audible.Authenticator):
33
+ _with_username: Optional[bool] = False
34
+ _serial: Optional[str] = None
35
+ _code_verifier: Optional[bytes] = None
36
+ oauth_url: Optional[str] = None
37
+
38
+ @classmethod
39
+ def from_locale(
40
+ cls,
41
+ locale: Union[str, "Locale"],
42
+ serial: Optional[str] = None,
43
+ with_username: bool = False,
44
+ ):
45
+ auth = cls()
46
+ auth.locale = locale
47
+ auth._with_username = with_username
48
+ auth._code_verifier = create_code_verifier()
49
+ auth.oauth_url, auth._serial = build_oauth_url(
50
+ country_code=auth.locale.country_code,
51
+ domain=auth.locale.domain,
52
+ market_place_id=auth.locale.market_place_id,
53
+ code_verifier=auth._code_verifier,
54
+ serial=serial,
55
+ with_username=with_username
56
+ )
57
+
58
+ return auth
59
+
60
+ def external_login(self, response_url: str):
61
+
62
+ login_device = external_login(
63
+ response_url,
64
+ self.locale.domain,
65
+ self._serial,
66
+ self._code_verifier
67
+ )
68
+ logger.info("logged in to Audible.")
69
+
70
+ register_device = register_(
71
+ with_username=self._with_username,
72
+ **login_device
73
+ )
74
+
75
+ self._update_attrs(
76
+ with_username=self._with_username,
77
+ **register_device
78
+ )
79
+ logger.info("registered Audible device")
@@ -1,5 +1,6 @@
1
1
  import asyncio
2
2
  import math
3
+ from typing import Union
3
4
 
4
5
  from tbr_deal_finder.config import Config
5
6
  from tbr_deal_finder.retailer.amazon import Amazon
@@ -20,7 +21,7 @@ class Audible(Amazon):
20
21
  self,
21
22
  target: Book,
22
23
  semaphore: asyncio.Semaphore
23
- ) -> Book:
24
+ ) -> Union[Book, None]:
24
25
  title = target.title
25
26
  authors = target.authors
26
27