tbr-deal-finder 0.2.0__py3-none-any.whl → 0.3.2__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 +754 -0
- tbr_deal_finder/gui/pages/__init__.py +1 -0
- tbr_deal_finder/gui/pages/all_books.py +100 -0
- tbr_deal_finder/gui/pages/all_deals.py +63 -0
- tbr_deal_finder/gui/pages/base_book_page.py +290 -0
- tbr_deal_finder/gui/pages/book_details.py +604 -0
- tbr_deal_finder/gui/pages/latest_deals.py +376 -0
- tbr_deal_finder/gui/pages/settings.py +390 -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 +69 -37
- tbr_deal_finder/tracked_books.py +76 -8
- tbr_deal_finder/utils.py +74 -3
- tbr_deal_finder/version_check.py +40 -0
- {tbr_deal_finder-0.2.0.dist-info → tbr_deal_finder-0.3.2.dist-info}/METADATA +19 -88
- tbr_deal_finder-0.3.2.dist-info/RECORD +38 -0
- {tbr_deal_finder-0.2.0.dist-info → tbr_deal_finder-0.3.2.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.2.dist-info}/WHEEL +0 -0
- {tbr_deal_finder-0.2.0.dist-info → tbr_deal_finder-0.3.2.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,390 @@
|
|
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.enable_navigation()
|
313
|
+
self.app.config_updated(self.config)
|
314
|
+
|
315
|
+
except Exception as ex:
|
316
|
+
self.show_error(f"Error saving configuration: {str(ex)}")
|
317
|
+
|
318
|
+
def cancel_changes(self, e):
|
319
|
+
"""Cancel changes and return to main view"""
|
320
|
+
self.load_current_config()
|
321
|
+
if self.app.config:
|
322
|
+
self.app.current_page = "all_deals"
|
323
|
+
self.app.nav_rail.selected_index = 0
|
324
|
+
else:
|
325
|
+
self.app.current_page = "all_deals"
|
326
|
+
self.app.update_content()
|
327
|
+
|
328
|
+
def show_error(self, message: str):
|
329
|
+
"""Show error dialog"""
|
330
|
+
dlg = ft.AlertDialog(
|
331
|
+
title=ft.Text("Error"),
|
332
|
+
content=ft.Text(message),
|
333
|
+
actions=[ft.TextButton("OK", on_click=lambda e: self.close_dialog(dlg))]
|
334
|
+
)
|
335
|
+
self.app.page.overlay.append(dlg)
|
336
|
+
dlg.open = True
|
337
|
+
self.app.page.update()
|
338
|
+
|
339
|
+
def show_success(self, message: str):
|
340
|
+
"""Show success dialog"""
|
341
|
+
dlg = ft.AlertDialog(
|
342
|
+
title=ft.Text("Success"),
|
343
|
+
content=ft.Text(message),
|
344
|
+
actions=[ft.TextButton("OK", on_click=lambda e: self.close_dialog(dlg))]
|
345
|
+
)
|
346
|
+
self.app.page.overlay.append(dlg)
|
347
|
+
dlg.open = True
|
348
|
+
self.app.page.update()
|
349
|
+
|
350
|
+
def show_text_input_dialog(self):
|
351
|
+
"""Fallback: Show text input dialog for manual path entry"""
|
352
|
+
self.path_input = ft.TextField(
|
353
|
+
label="Enter full path to CSV file",
|
354
|
+
hint_text="/path/to/your/export.csv",
|
355
|
+
expand=True
|
356
|
+
)
|
357
|
+
|
358
|
+
def add_manual_path(e):
|
359
|
+
path = self.path_input.value.strip()
|
360
|
+
if path and path not in self.library_paths:
|
361
|
+
import os
|
362
|
+
if os.path.exists(path) and path.endswith('.csv'):
|
363
|
+
self.library_paths.append(path)
|
364
|
+
self.update_library_paths_list()
|
365
|
+
self.close_dialog(path_dialog)
|
366
|
+
else:
|
367
|
+
self.show_error("File does not exist or is not a CSV file")
|
368
|
+
else:
|
369
|
+
self.show_error("Please enter a valid path")
|
370
|
+
|
371
|
+
path_dialog = ft.AlertDialog(
|
372
|
+
title=ft.Text("Enter File Path"),
|
373
|
+
content=ft.Column([
|
374
|
+
ft.Text("File picker not available. Please enter the full path to your library export CSV file:"),
|
375
|
+
self.path_input
|
376
|
+
], tight=True),
|
377
|
+
actions=[
|
378
|
+
ft.TextButton("Cancel", on_click=lambda e: self.close_dialog(path_dialog)),
|
379
|
+
ft.ElevatedButton("Add Path", on_click=add_manual_path)
|
380
|
+
]
|
381
|
+
)
|
382
|
+
|
383
|
+
self.app.page.overlay.append(path_dialog)
|
384
|
+
path_dialog.open = True
|
385
|
+
self.app.page.update()
|
386
|
+
|
387
|
+
def close_dialog(self, dialog):
|
388
|
+
"""Close dialog"""
|
389
|
+
dialog.open = False
|
390
|
+
self.app.page.update()
|
tbr_deal_finder/migrations.py
CHANGED
@@ -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
|
|
@@ -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
|
-
|
24
|
+
logger = logging.getLogger(__name__)
|
19
25
|
|
20
26
|
|
21
|
-
def
|
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
|
88
|
+
if not self.user_is_authed():
|
75
89
|
auth = audible.Authenticator.from_login_external(
|
76
90
|
locale=Config.locale,
|
77
|
-
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(
|
95
|
+
auth.to_file(AUTH_PATH)
|
82
96
|
|
83
|
-
self._auth = audible.Authenticator.from_file(
|
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
|
|