tbr-deal-finder 0.1.4__tar.gz → 0.1.6__tar.gz

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 (28) hide show
  1. tbr_deal_finder-0.1.6/CHANGELOG.md +21 -0
  2. {tbr_deal_finder-0.1.4 → tbr_deal_finder-0.1.6}/PKG-INFO +4 -3
  3. {tbr_deal_finder-0.1.4 → tbr_deal_finder-0.1.6}/README.md +3 -2
  4. {tbr_deal_finder-0.1.4 → tbr_deal_finder-0.1.6}/pyproject.toml +1 -1
  5. {tbr_deal_finder-0.1.4 → tbr_deal_finder-0.1.6}/tbr_deal_finder/book.py +18 -5
  6. {tbr_deal_finder-0.1.4 → tbr_deal_finder-0.1.6}/tbr_deal_finder/cli.py +43 -19
  7. tbr_deal_finder-0.1.6/tbr_deal_finder/library_exports.py +205 -0
  8. {tbr_deal_finder-0.1.4 → tbr_deal_finder-0.1.6}/tbr_deal_finder/retailer/audible.py +53 -26
  9. tbr_deal_finder-0.1.6/tbr_deal_finder/retailer/chirp.py +182 -0
  10. {tbr_deal_finder-0.1.4 → tbr_deal_finder-0.1.6}/tbr_deal_finder/retailer/librofm.py +75 -20
  11. {tbr_deal_finder-0.1.4 → tbr_deal_finder-0.1.6}/tbr_deal_finder/retailer/models.py +18 -2
  12. {tbr_deal_finder-0.1.4 → tbr_deal_finder-0.1.6}/tbr_deal_finder/retailer_deal.py +19 -10
  13. tbr_deal_finder-0.1.6/tbr_deal_finder/tracked_books.py +120 -0
  14. {tbr_deal_finder-0.1.4 → tbr_deal_finder-0.1.6}/tbr_deal_finder/utils.py +17 -0
  15. {tbr_deal_finder-0.1.4 → tbr_deal_finder-0.1.6}/uv.lock +1 -1
  16. tbr_deal_finder-0.1.4/tbr_deal_finder/library_exports.py +0 -155
  17. tbr_deal_finder-0.1.4/tbr_deal_finder/retailer/chirp.py +0 -79
  18. {tbr_deal_finder-0.1.4 → tbr_deal_finder-0.1.6}/.github/workflows/publish-to-pypi.yaml +0 -0
  19. {tbr_deal_finder-0.1.4 → tbr_deal_finder-0.1.6}/.gitignore +0 -0
  20. {tbr_deal_finder-0.1.4 → tbr_deal_finder-0.1.6}/.python-version +0 -0
  21. {tbr_deal_finder-0.1.4 → tbr_deal_finder-0.1.6}/LICENSE +0 -0
  22. {tbr_deal_finder-0.1.4 → tbr_deal_finder-0.1.6}/tbr_deal_finder/__init__.py +0 -0
  23. {tbr_deal_finder-0.1.4 → tbr_deal_finder-0.1.6}/tbr_deal_finder/config.py +0 -0
  24. {tbr_deal_finder-0.1.4 → tbr_deal_finder-0.1.6}/tbr_deal_finder/migrations.py +0 -0
  25. {tbr_deal_finder-0.1.4 → tbr_deal_finder-0.1.6}/tbr_deal_finder/queries/get_active_deals.sql +0 -0
  26. {tbr_deal_finder-0.1.4 → tbr_deal_finder-0.1.6}/tbr_deal_finder/queries/get_deals_found_at.sql +0 -0
  27. {tbr_deal_finder-0.1.4 → tbr_deal_finder-0.1.6}/tbr_deal_finder/queries/latest_deal_last_ran_most_recent_success.sql +0 -0
  28. {tbr_deal_finder-0.1.4 → tbr_deal_finder-0.1.6}/tbr_deal_finder/retailer/__init__.py +0 -0
@@ -0,0 +1,21 @@
1
+
2
+ # Change Log
3
+
4
+ ## 0.1.6 (July 30, 2025)
5
+
6
+ Notes:
7
+ * tbr-deal-finder now also tracks deals on the books in your wishlist. Works for all retailers.
8
+
9
+ BUG FIXES:
10
+ * Fixed issue where no deals would display if libro is the only tracked audiobook retailer.
11
+ * Fixed retailer cli setup forcing a user to select at least two audiobook retailers.
12
+
13
+ ## 0.1.5 (July 30, 2025)
14
+
15
+ Notes:
16
+ * Added formatting to select messages to make the messages purpose clearer.
17
+
18
+ BUG FIXES:
19
+ * Fixed issue getting books from libro and chirp too aggressively
20
+ * User must now track deals for at least one retailer
21
+
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tbr-deal-finder
3
- Version: 0.1.4
3
+ Version: 0.1.6
4
4
  Summary: Track price drops and find deals on books in your TBR list across audiobook and ebook formats.
5
5
  License: MIT
6
6
  License-File: LICENSE
@@ -21,7 +21,8 @@ Track price drops and find deals on books in your TBR (To Be Read) list across a
21
21
 
22
22
  ## Features
23
23
  - Uses your StoryGraph exports, Goodreads exports, and custom csvs (spreadsheet) to track book deals
24
- - Supports multiple of the library exports above
24
+ - Supports multiple of the library exports above
25
+ - Tracks deals on the wishlist of all your configured retailers like audible
25
26
  - Supports multiple locales and currencies
26
27
  - Finds the latest and active deals from supported sellers
27
28
  - Simple CLI interface for setup and usage
@@ -32,7 +33,7 @@ Track price drops and find deals on books in your TBR (To Be Read) list across a
32
33
  ### Audiobooks
33
34
  * Audible
34
35
  * Chirp
35
- * Libro.fm (Work in progress)
36
+ * Libro.fm
36
37
 
37
38
  ### Locales
38
39
  * US
@@ -4,7 +4,8 @@ Track price drops and find deals on books in your TBR (To Be Read) list across a
4
4
 
5
5
  ## Features
6
6
  - Uses your StoryGraph exports, Goodreads exports, and custom csvs (spreadsheet) to track book deals
7
- - Supports multiple of the library exports above
7
+ - Supports multiple of the library exports above
8
+ - Tracks deals on the wishlist of all your configured retailers like audible
8
9
  - Supports multiple locales and currencies
9
10
  - Finds the latest and active deals from supported sellers
10
11
  - Simple CLI interface for setup and usage
@@ -15,7 +16,7 @@ Track price drops and find deals on books in your TBR (To Be Read) list across a
15
16
  ### Audiobooks
16
17
  * Audible
17
18
  * Chirp
18
- * Libro.fm (Work in progress)
19
+ * Libro.fm
19
20
 
20
21
  ### Locales
21
22
  * US
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "tbr-deal-finder"
3
- version = "0.1.4"
3
+ version = "0.1.6"
4
4
  description = "Track price drops and find deals on books in your TBR list across audiobook and ebook formats."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.13"
@@ -26,7 +26,11 @@ class Book:
26
26
  current_price: float
27
27
  timepoint: datetime
28
28
  format: Union[BookFormat, str]
29
+
30
+ # Metadata really only used for tracked books.
31
+ # See get_tbr_books for more context
29
32
  audiobook_isbn: str = None
33
+ audiobook_list_price: float = 0
30
34
 
31
35
  deleted: bool = False
32
36
 
@@ -35,16 +39,16 @@ class Book:
35
39
  normalized_authors: list[str] = None
36
40
 
37
41
  def __post_init__(self):
42
+ self.current_price = round(self.current_price, 2)
43
+ self.list_price = round(self.list_price, 2)
44
+ self.normalized_authors = get_normalized_authors(self.authors)
45
+
38
46
  if not self.deal_id:
39
- self.deal_id = f"{self.title}__{self.normalized_authors}__{self.retailer}__{self.format}"
47
+ self.deal_id = f"{self.title}__{self.normalized_authors}__{self.format}__{self.retailer}"
40
48
 
41
49
  if isinstance(self.format, str):
42
50
  self.format = BookFormat(self.format)
43
51
 
44
- self.current_price = round(self.current_price, 2)
45
- self.list_price = round(self.list_price, 2)
46
- self.normalized_authors = get_normalized_authors(self.authors)
47
-
48
52
  def discount(self) -> int:
49
53
  return int((self.list_price/self.current_price - 1) * 100)
50
54
 
@@ -56,6 +60,10 @@ class Book:
56
60
  def title_id(self) -> str:
57
61
  return f"{self.title}__{self.normalized_authors}__{self.format}"
58
62
 
63
+ @property
64
+ def full_title_str(self) -> str:
65
+ return f"{self.title}__{self.normalized_authors}"
66
+
59
67
  def list_price_string(self):
60
68
  return self.price_to_string(self.list_price)
61
69
 
@@ -74,6 +82,7 @@ class Book:
74
82
  response = dataclasses.asdict(self)
75
83
  response["format"] = self.format.value
76
84
  del response["audiobook_isbn"]
85
+ del response["audiobook_list_price"]
77
86
  del response["exists"]
78
87
  del response["normalized_authors"]
79
88
 
@@ -114,3 +123,7 @@ def get_normalized_authors(authors: Union[str, list[str]]) -> list[str]:
114
123
  authors = [i for i in authors.split(",")]
115
124
 
116
125
  return sorted([_AUTHOR_RE.sub('', unidecode(author)).lower() for author in authors])
126
+
127
+
128
+ def get_title_id(title: str, authors: Union[list, str], book_format: BookFormat) -> str:
129
+ return f"{title}__{get_normalized_authors(authors)}__{book_format.value}"
@@ -1,5 +1,6 @@
1
1
  import asyncio
2
2
  import os
3
+ import sys
3
4
  from datetime import timedelta
4
5
  from textwrap import dedent
5
6
  from typing import Union
@@ -8,12 +9,19 @@ import click
8
9
  import questionary
9
10
 
10
11
  from tbr_deal_finder.config import Config
11
- from tbr_deal_finder.library_exports import maybe_set_library_export_audiobook_isbn
12
+ from tbr_deal_finder.library_exports import maybe_enrich_library_exports
12
13
  from tbr_deal_finder.migrations import make_migrations
13
14
  from tbr_deal_finder.book import get_deals_found_at, print_books, get_active_deals
14
15
  from tbr_deal_finder.retailer import RETAILER_MAP
15
16
  from tbr_deal_finder.retailer_deal import get_latest_deals
16
- from tbr_deal_finder.utils import get_duckdb_conn, get_query_by_name, execute_query
17
+ from tbr_deal_finder.utils import (
18
+ echo_err,
19
+ echo_info,
20
+ echo_success,
21
+ execute_query,
22
+ get_duckdb_conn,
23
+ get_query_by_name
24
+ )
17
25
 
18
26
 
19
27
  @click.group()
@@ -31,12 +39,12 @@ def _add_path(existing_paths: list[str]) -> Union[str, None]:
31
39
  try:
32
40
  new_path = os.path.expanduser(click.prompt("What is the new path"))
33
41
  if new_path in existing_paths:
34
- click.echo(f"{new_path} is already being tracked.\n")
42
+ echo_info(f"{new_path} is already being tracked.\n")
35
43
  return None
36
44
  elif os.path.exists(new_path):
37
45
  return new_path
38
46
  else:
39
- click.echo(f"Could not find {new_path}. Please try again.\n")
47
+ echo_err(f"Could not find {new_path}. Please try again.\n")
40
48
  return _add_path(existing_paths)
41
49
  except (KeyError, KeyboardInterrupt, TypeError):
42
50
  return None
@@ -116,14 +124,26 @@ def _set_locale(config: Config):
116
124
 
117
125
 
118
126
  def _set_tracked_retailers(config: Config):
119
- config.set_tracked_retailers(
120
- questionary.checkbox(
121
- "Select the retailers you want to check deals for. "
122
- "Tip: Chirp doesn't have a subscription and can have good deals. I'd recommend checking it.",
127
+ if not config.tracked_retailers:
128
+ echo_info(
129
+ "If you haven't heard of it, Chirp doesn't charge a subscription and has some great deals. \n"
130
+ "Note: I don't work for Chirp and this isn't a paid plug."
131
+ )
132
+
133
+ while True:
134
+ user_response = questionary.checkbox(
135
+ "Select the retailers you want to check deals for.\n",
123
136
  choices=[
124
137
  questionary.Choice(retailer, checked=retailer in config.tracked_retailers)
125
138
  for retailer in RETAILER_MAP.keys()
126
- ]).ask()
139
+ ]).ask()
140
+ if len(user_response) > 0:
141
+ break
142
+ else:
143
+ echo_err("You must track deals for at least one retailer.")
144
+
145
+ config.set_tracked_retailers(
146
+ user_response
127
147
  )
128
148
 
129
149
 
@@ -133,10 +153,14 @@ def _set_config() -> Config:
133
153
  except FileNotFoundError:
134
154
  config = Config(library_export_paths=[], tracked_retailers=list(RETAILER_MAP.keys()))
135
155
 
136
- # Setting these config values are a little more involved,
137
- # so they're broken out into their own functions
138
- _set_library_export_paths(config)
139
- _set_tracked_retailers(config)
156
+ try:
157
+ # Config attrs that requires a user provided value
158
+ _set_library_export_paths(config)
159
+ _set_tracked_retailers(config)
160
+ except (KeyError, KeyboardInterrupt, TypeError):
161
+ echo_err("Config setup cancelled.")
162
+ sys.exit(0)
163
+
140
164
  _set_locale(config)
141
165
 
142
166
  config.max_price = click.prompt(
@@ -151,7 +175,7 @@ def _set_config() -> Config:
151
175
  )
152
176
 
153
177
  config.save()
154
- click.echo("Configuration saved!")
178
+ echo_success("Configuration saved!")
155
179
 
156
180
  return config
157
181
 
@@ -166,7 +190,7 @@ def latest_deals():
166
190
  """Find book deals from your Library export."""
167
191
  config = Config.load()
168
192
 
169
- asyncio.run(maybe_set_library_export_audiobook_isbn(config))
193
+ asyncio.run(maybe_enrich_library_exports(config))
170
194
 
171
195
  db_conn = get_duckdb_conn()
172
196
  results = execute_query(
@@ -182,7 +206,7 @@ def latest_deals():
182
206
  except Exception as e:
183
207
  ran_successfully = False
184
208
  details = f"Error getting deals: {e}"
185
- click.echo(details)
209
+ echo_err(details)
186
210
  else:
187
211
  ran_successfully = True
188
212
  details = ""
@@ -198,7 +222,7 @@ def latest_deals():
198
222
  return
199
223
 
200
224
  else:
201
- click.echo(dedent("""
225
+ echo_info(dedent("""
202
226
  To prevent abuse lastest deals can only be pulled every 8 hours.
203
227
  Fetching most recent deal results.\n
204
228
  """))
@@ -207,7 +231,7 @@ def latest_deals():
207
231
  if books := get_deals_found_at(config.run_time):
208
232
  print_books(books)
209
233
  else:
210
- click.echo("No new deals found.")
234
+ echo_info("No new deals found.")
211
235
 
212
236
 
213
237
  @cli.command()
@@ -216,7 +240,7 @@ def active_deals():
216
240
  if books := get_active_deals():
217
241
  print_books(books)
218
242
  else:
219
- click.echo("No deals found.")
243
+ echo_info("No deals found.")
220
244
 
221
245
 
222
246
  if __name__ == '__main__':
@@ -0,0 +1,205 @@
1
+ import asyncio
2
+ import csv
3
+ import shutil
4
+ import tempfile
5
+ from datetime import datetime
6
+ from typing import Callable, Awaitable, Optional
7
+
8
+ from tqdm.asyncio import tqdm_asyncio
9
+
10
+ from tbr_deal_finder.book import Book, BookFormat, get_normalized_authors
11
+ from tbr_deal_finder.config import Config
12
+ from tbr_deal_finder.retailer import LibroFM, Chirp
13
+
14
+
15
+ def get_book_authors(book: dict) -> str:
16
+ if authors := book.get('Authors'):
17
+ return authors
18
+
19
+ authors = book['Author']
20
+ if additional_authors := book.get("Additional Authors"):
21
+ authors = f"{authors}, {additional_authors}"
22
+
23
+ return authors
24
+
25
+
26
+ def get_book_title(book: dict) -> str:
27
+ title = book['Title']
28
+ return title.split("(")[0].strip()
29
+
30
+
31
+ def is_tbr_book(book: dict) -> bool:
32
+ if "Read Status" in book:
33
+ return book["Read Status"] == "to-read"
34
+ elif "Bookshelves" in book:
35
+ return "to-read" in book["Bookshelves"]
36
+ else:
37
+ return True
38
+
39
+
40
+ def requires_audiobook_list_price_default(config: Config) -> bool:
41
+ return bool(
42
+ "Libro.FM" in config.tracked_retailers
43
+ and "Audible" not in config.tracked_retailers
44
+ and "Chirp" not in config.tracked_retailers
45
+ )
46
+
47
+
48
+ async def _maybe_set_column_for_library_exports(
49
+ config: Config,
50
+ attr_name: str,
51
+ get_book_callable: Callable[[Book, datetime, asyncio.Semaphore], Awaitable[Book]],
52
+ column_name: Optional[str] = None,
53
+ ):
54
+ """Adds a new column to all library exports that are missing it.
55
+ Uses get_book_callable to set the column value if a matching record couldn't be found
56
+ on that column in any other library export file.
57
+
58
+ :param config:
59
+ :param attr_name:
60
+ :param get_book_callable:
61
+ :param column_name:
62
+ :return:
63
+ """
64
+ if not column_name:
65
+ column_name = attr_name
66
+
67
+ books_requiring_check_map = dict()
68
+ book_to_col_val_map = dict()
69
+
70
+ # Iterate all library export paths
71
+ for library_export_path in config.library_export_paths:
72
+ with open(library_export_path, 'r', newline='', encoding='utf-8') as file:
73
+ # Use csv.DictReader to get dictionaries with column headers
74
+ for book_dict in csv.DictReader(file):
75
+ if not is_tbr_book(book_dict):
76
+ continue
77
+
78
+ title = get_book_title(book_dict)
79
+ authors = get_book_authors(book_dict)
80
+ key = f'{title}__{get_normalized_authors(authors)}'
81
+
82
+ if column_name in book_dict:
83
+ # Keep state of value for this book/key
84
+ # in the event another export has the same book but the value is not set
85
+ book_to_col_val_map[key] = book_dict[column_name]
86
+ # Value has been found so a check no longer needs to be performed
87
+ books_requiring_check_map.pop(key, None)
88
+ elif key not in book_to_col_val_map:
89
+ # Not found, add the book to those requiring the column val to be set
90
+ books_requiring_check_map[key] = Book(
91
+ retailer="N/A",
92
+ title=title,
93
+ authors=authors,
94
+ list_price=0,
95
+ current_price=0,
96
+ timepoint=config.run_time,
97
+ format=BookFormat.NA
98
+ )
99
+
100
+ if not books_requiring_check_map:
101
+ # Everything was resolved, nothing else to do
102
+ return
103
+
104
+ semaphore = asyncio.Semaphore(5)
105
+ human_readable_name = attr_name.replace("_", " ").title()
106
+
107
+ # Get books with the appropriate transform applied
108
+ # Responsibility is on the callable here
109
+ enriched_books = await tqdm_asyncio.gather(
110
+ *[
111
+ get_book_callable(book, config.run_time, semaphore) for book in books_requiring_check_map.values()
112
+ ],
113
+ desc=f"Getting required {human_readable_name} info"
114
+ )
115
+ updated_book_map = {
116
+ b.full_title_str: b
117
+ for b in enriched_books
118
+ }
119
+
120
+
121
+ # Go back and now add the new column where it hasn't been set
122
+ for library_export_path in config.library_export_paths:
123
+ with open(library_export_path, 'r', newline='', encoding='utf-8') as file:
124
+ reader = csv.DictReader(file)
125
+ field_names = list(reader.fieldnames) + [column_name]
126
+ file_content = [book_dict for book_dict in reader]
127
+ if not file_content or column_name in file_content[0]:
128
+ continue
129
+
130
+ with tempfile.NamedTemporaryFile(mode='w', delete=False, newline='') as temp_file:
131
+ temp_filename = temp_file.name
132
+ writer = csv.DictWriter(temp_file, fieldnames=field_names)
133
+ writer.writeheader()
134
+
135
+ for book_dict in file_content:
136
+ if is_tbr_book(book_dict):
137
+ title = get_book_title(book_dict)
138
+ authors = get_book_authors(book_dict)
139
+ key = f'{title}__{get_normalized_authors(authors)}'
140
+
141
+ if key in book_to_col_val_map:
142
+ col_val = book_to_col_val_map[key]
143
+ elif key in updated_book_map:
144
+ book = updated_book_map[key]
145
+ col_val = getattr(book, attr_name)
146
+ else:
147
+ col_val = ""
148
+
149
+ book_dict[column_name] = col_val
150
+ else:
151
+ book_dict[column_name] = ""
152
+
153
+ writer.writerow(book_dict)
154
+
155
+ shutil.move(temp_filename, library_export_path)
156
+
157
+
158
+ async def _maybe_set_library_export_audiobook_isbn(config: Config):
159
+ """To get the price from Libro.fm for a book, you need its ISBN
160
+
161
+ As opposed to trying to get that every time latest-deals is run
162
+ we're just updating the export csv once to include the ISBN.
163
+
164
+ Unfortunately, we do have to get it at run time for wishlists.
165
+ """
166
+ if "Libro.FM" not in config.tracked_retailers:
167
+ return
168
+
169
+ libro_fm = LibroFM()
170
+ await libro_fm.set_auth()
171
+
172
+ await _maybe_set_column_for_library_exports(
173
+ config,
174
+ "audiobook_isbn",
175
+ libro_fm.get_book_isbn,
176
+ )
177
+
178
+
179
+ async def _maybe_set_library_export_audiobook_list_price(config: Config):
180
+ """Set a default list price for audiobooks
181
+
182
+ Only set if not currently set and the only audiobook retailer is Libro.FM
183
+ Libro.FM doesn't include the actual default price in its response, so this grabs the price reported by Chirp.
184
+ Chirp doesn't require a login to get this price info making it ideal in this instance.
185
+
186
+ :param config:
187
+ :return:
188
+ """
189
+ if not requires_audiobook_list_price_default(config):
190
+ return
191
+
192
+ chirp = Chirp()
193
+ await chirp.set_auth()
194
+
195
+ await _maybe_set_column_for_library_exports(
196
+ config,
197
+ "list_price",
198
+ chirp.get_book,
199
+ "audiobook_list_price"
200
+ )
201
+
202
+
203
+ async def maybe_enrich_library_exports(config: Config):
204
+ await _maybe_set_library_export_audiobook_isbn(config)
205
+ await _maybe_set_library_export_audiobook_list_price(config)
@@ -1,4 +1,5 @@
1
1
  import asyncio
2
+ import math
2
3
  import os.path
3
4
  from datetime import datetime
4
5
  from textwrap import dedent
@@ -22,11 +23,9 @@ def login_url_callback(url: str) -> str:
22
23
 
23
24
  try:
24
25
  from playwright.sync_api import sync_playwright # type: ignore
25
- use_playwright = True
26
26
  except ImportError:
27
- use_playwright = False
28
-
29
- if use_playwright:
27
+ pass
28
+ else:
30
29
  with sync_playwright() as p:
31
30
  iphone = p.devices["iPhone 12 Pro"]
32
31
  browser = p.webkit.launch(headless=False)
@@ -75,6 +74,23 @@ class Audible(Retailer):
75
74
  def name(self) -> str:
76
75
  return "Audible"
77
76
 
77
+ @property
78
+ def format(self) -> BookFormat:
79
+ return BookFormat.AUDIOBOOK
80
+
81
+ async def set_auth(self):
82
+ if not os.path.exists(_AUTH_PATH):
83
+ auth = audible.Authenticator.from_login_external(
84
+ locale=Config.locale,
85
+ login_url_callback=login_url_callback
86
+ )
87
+
88
+ # Save credentials to file
89
+ auth.to_file(_AUTH_PATH)
90
+
91
+ self._auth = audible.Authenticator.from_file(_AUTH_PATH)
92
+ self._client = audible.AsyncClient(auth=self._auth)
93
+
78
94
  async def get_book(
79
95
  self,
80
96
  target: Book,
@@ -95,19 +111,7 @@ class Audible(Retailer):
95
111
  ]
96
112
  )
97
113
 
98
- if not match["products"]:
99
- return Book(
100
- retailer=self.name,
101
- title=title,
102
- authors=authors,
103
- list_price=0,
104
- current_price=0,
105
- timepoint=runtime,
106
- format=BookFormat.AUDIOBOOK,
107
- exists=False,
108
- )
109
-
110
- for product in match["products"]:
114
+ for product in match.get("products", []):
111
115
  if product["title"] != title:
112
116
  continue
113
117
 
@@ -132,15 +136,38 @@ class Audible(Retailer):
132
136
  exists=False,
133
137
  )
134
138
 
135
- async def set_auth(self):
136
- if not os.path.exists(_AUTH_PATH):
137
- auth = audible.Authenticator.from_login_external(
138
- locale=Config.locale,
139
- login_url_callback=login_url_callback
139
+ async def get_wishlist(self, config: Config) -> list[Book]:
140
+ wishlist_books = []
141
+
142
+ page = 0
143
+ total_pages = 1
144
+ page_size = 50
145
+ while page < total_pages:
146
+ response = await self._client.get(
147
+ "1.0/wishlist",
148
+ num_results=page_size,
149
+ page=page,
150
+ response_groups=[
151
+ "contributors, product_attrs, product_desc, product_extended_attrs"
152
+ ]
140
153
  )
141
154
 
142
- # Save credentials to file
143
- auth.to_file(_AUTH_PATH)
155
+ for audiobook in response.get("products", []):
156
+ authors = [author["name"] for author in audiobook["authors"]]
157
+ wishlist_books.append(
158
+ Book(
159
+ retailer=self.name,
160
+ title=audiobook["title"],
161
+ authors=", ".join(authors),
162
+ list_price=1,
163
+ current_price=1,
164
+ timepoint=config.run_time,
165
+ format=self.format,
166
+ audiobook_isbn=audiobook["isbn"],
167
+ )
168
+ )
144
169
 
145
- self._auth = audible.Authenticator.from_file(_AUTH_PATH)
146
- self._client = audible.AsyncClient(auth=self._auth)
170
+ page += 1
171
+ total_pages = math.ceil(int(response.get("total_results", 1))/page_size)
172
+
173
+ return wishlist_books