tbr-deal-finder 0.1.5__py3-none-any.whl → 0.1.7__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.
@@ -1,21 +1,88 @@
1
1
  import asyncio
2
- from datetime import datetime
2
+ import json
3
+ import os
4
+ from datetime import datetime, timedelta
5
+ from textwrap import dedent
3
6
 
4
7
  import aiohttp
8
+ import click
5
9
 
10
+ from tbr_deal_finder import TBR_DEALS_PATH
11
+ from tbr_deal_finder.config import Config
6
12
  from tbr_deal_finder.retailer.models import Retailer
7
- from tbr_deal_finder.book import Book, BookFormat, get_normalized_authors
8
- from tbr_deal_finder.utils import currency_to_float
13
+ from tbr_deal_finder.book import Book, BookFormat, get_normalized_authors, is_matching_authors
14
+ from tbr_deal_finder.utils import currency_to_float, echo_err
9
15
 
10
16
 
11
17
  class Chirp(Retailer):
12
18
  # Static because url for other locales just redirects to .com
13
- _url: str = "https://www.chirpbooks.com/api/graphql"
19
+ _url: str = "https://api.chirpbooks.com/api/graphql"
20
+ USER_AGENT = "ChirpBooks/5.13.9 (Android)"
21
+
22
+ def __init__(self):
23
+ self.auth_token = None
14
24
 
15
25
  @property
16
26
  def name(self) -> str:
17
27
  return "Chirp"
18
28
 
29
+ @property
30
+ def format(self) -> BookFormat:
31
+ return BookFormat.AUDIOBOOK
32
+
33
+ async def make_request(self, request_type: str, **kwargs) -> dict:
34
+ headers = kwargs.pop("headers", {})
35
+ headers["Accept"] = "application/json"
36
+ headers["Content-Type"] = "application/json"
37
+ headers["User-Agent"] = self.USER_AGENT
38
+ if self.auth_token:
39
+ headers["authorization"] = f"Bearer {self.auth_token}"
40
+
41
+ async with aiohttp.ClientSession() as http_client:
42
+ response = await http_client.request(
43
+ request_type.upper(),
44
+ self._url,
45
+ headers=headers,
46
+ **kwargs
47
+ )
48
+ if response.ok:
49
+ return await response.json()
50
+ else:
51
+ return {}
52
+
53
+ async def set_auth(self):
54
+ auth_path = TBR_DEALS_PATH.joinpath("chirp.json")
55
+ if os.path.exists(auth_path):
56
+ with open(auth_path, "r") as f:
57
+ auth_info = json.load(f)
58
+ if auth_info:
59
+ token_created_at = datetime.fromtimestamp(auth_info["created_at"])
60
+ max_token_age = datetime.now() - timedelta(days=5)
61
+ if token_created_at > max_token_age:
62
+ self.auth_token = auth_info["data"]["signIn"]["user"]["token"]
63
+ return
64
+
65
+ response = await self.make_request(
66
+ "POST",
67
+ json={
68
+ "query": "mutation signIn($email: String!, $password: String!) { signIn(email: $email, password: $password) { user { id token webToken email } } }",
69
+ "variables": {
70
+ "email": click.prompt("Chirp account email"),
71
+ "password": click.prompt("Chirp Password", hide_input=True),
72
+ }
73
+ }
74
+ )
75
+ if not response:
76
+ echo_err("Chirp login failed, please try again.")
77
+ await self.set_auth()
78
+
79
+ # Set token for future requests during the current execution
80
+ self.auth_token = response["data"]["signIn"]["user"]["token"]
81
+
82
+ response["created_at"] = datetime.now().timestamp()
83
+ with open(auth_path, "w") as f:
84
+ json.dump(response, f)
85
+
19
86
  async def get_book(
20
87
  self, target: Book, runtime: datetime, semaphore: asyncio.Semaphore
21
88
  ) -> Book:
@@ -51,9 +118,10 @@ class Chirp(Retailer):
51
118
  if not book["currentProduct"]:
52
119
  continue
53
120
 
121
+ normalized_authors = get_normalized_authors([author["name"] for author in book["allAuthors"]])
54
122
  if (
55
123
  book["displayTitle"] == title
56
- and get_normalized_authors(book["displayAuthors"]) == target.normalized_authors
124
+ and is_matching_authors(target.normalized_authors, normalized_authors)
57
125
  ):
58
126
  return Book(
59
127
  retailer=self.name,
@@ -76,5 +144,98 @@ class Chirp(Retailer):
76
144
  exists=False,
77
145
  )
78
146
 
79
- async def set_auth(self):
80
- return
147
+ async def get_wishlist(self, config: Config) -> list[Book]:
148
+ wishlist_books = []
149
+ page = 1
150
+
151
+ while True:
152
+ response = await self.make_request(
153
+ "POST",
154
+ json={
155
+ "query": "fragment audiobookFields on Audiobook{id averageRating coverUrl displayAuthors displayTitle ratingsCount url allAuthors{name slug url}} fragment productFields on Product{discountPrice id isFreeListing listingPrice purchaseUrl savingsPercent showListingPrice timeLeft bannerType} query FetchWishlistDealAudiobooks($page:Int,$pageSize:Int){currentUserWishlist{paginatedItems(filter:\"currently_promoted\",sort:\"promotion_end_date\",salability:current_or_future){totalCount objects(page:$page,pageSize:$pageSize){... on WishlistItem{id audiobook{...audiobookFields currentProduct{...productFields}}}}}}}",
156
+ "variables": {"page": page, "pageSize": 15},
157
+ "operationName": "FetchWishlistDealAudiobooks"
158
+ }
159
+ )
160
+
161
+ audiobooks = response.get(
162
+ "data", {}
163
+ ).get("currentUserWishlist", {}).get("paginatedItems", {}).get("objects", [])
164
+
165
+ if not audiobooks:
166
+ return wishlist_books
167
+
168
+ for book in audiobooks:
169
+ audiobook = book["audiobook"]
170
+ authors = [author["name"] for author in audiobook["allAuthors"]]
171
+ wishlist_books.append(
172
+ Book(
173
+ retailer=self.name,
174
+ title=audiobook["displayTitle"],
175
+ authors=", ".join(authors),
176
+ list_price=1,
177
+ current_price=1,
178
+ timepoint=config.run_time,
179
+ format=self.format,
180
+ )
181
+ )
182
+
183
+ page += 1
184
+
185
+ async def get_library(self, config: Config) -> list[Book]:
186
+ library_books = []
187
+ page = 1
188
+ query = dedent("""
189
+ query AndroidCurrentUserAudiobooks($page: Int!, $pageSize: Int!) {
190
+ currentUserAudiobooks(page: $page, pageSize: $pageSize, sort: TITLE_A_Z, clientCapabilities: [CHIRP_AUDIO]) {
191
+ audiobook {
192
+ id
193
+ allAuthors{name}
194
+ displayTitle
195
+ displayAuthors
196
+ displayNarrators
197
+ durationMs
198
+ description
199
+ publisher
200
+ }
201
+ archived
202
+ playable
203
+ finishedAt
204
+ currentOverallOffsetMs
205
+ }
206
+ }
207
+ """)
208
+
209
+ while True:
210
+ response = await self.make_request(
211
+ "POST",
212
+ json={
213
+ "query": query,
214
+ "variables": {"page": page, "pageSize": 15},
215
+ "operationName": "AndroidCurrentUserAudiobooks"
216
+ }
217
+ )
218
+
219
+ audiobooks = response.get(
220
+ "data", {}
221
+ ).get("currentUserAudiobooks", [])
222
+
223
+ if not audiobooks:
224
+ return library_books
225
+
226
+ for book in audiobooks:
227
+ audiobook = book["audiobook"]
228
+ authors = [author["name"] for author in audiobook["allAuthors"]]
229
+ library_books.append(
230
+ Book(
231
+ retailer=self.name,
232
+ title=audiobook["displayTitle"],
233
+ authors=", ".join(authors),
234
+ list_price=1,
235
+ current_price=1,
236
+ timepoint=config.run_time,
237
+ format=self.format,
238
+ )
239
+ )
240
+
241
+ page += 1
@@ -3,14 +3,14 @@ import json
3
3
  import os
4
4
  import urllib.parse
5
5
  from datetime import datetime, timedelta
6
- from typing import Union
7
6
 
8
7
  import aiohttp
9
8
  import click
10
9
 
11
10
  from tbr_deal_finder import TBR_DEALS_PATH
11
+ from tbr_deal_finder.config import Config
12
12
  from tbr_deal_finder.retailer.models import Retailer
13
- from tbr_deal_finder.book import Book, BookFormat, get_normalized_authors
13
+ from tbr_deal_finder.book import Book, BookFormat, get_normalized_authors, is_matching_authors
14
14
  from tbr_deal_finder.utils import currency_to_float
15
15
 
16
16
 
@@ -33,11 +33,16 @@ class LibroFM(Retailer):
33
33
  def name(self) -> str:
34
34
  return "Libro.FM"
35
35
 
36
+ @property
37
+ def format(self) -> BookFormat:
38
+ return BookFormat.AUDIOBOOK
39
+
36
40
  async def make_request(self, url_path: str, request_type: str, **kwargs) -> dict:
37
41
  url = urllib.parse.urljoin(self.BASE_URL, url_path)
38
42
  headers = kwargs.pop("headers", {})
39
43
  headers["User-Agent"] = self.USER_AGENT
40
- headers["authorization"] = f"Bearer {self.auth_token}"
44
+ if self.auth_token:
45
+ headers["authorization"] = f"Bearer {self.auth_token}"
41
46
 
42
47
  async with aiohttp.ClientSession() as http_client:
43
48
  response = await http_client.request(
@@ -75,7 +80,9 @@ class LibroFM(Retailer):
75
80
  with open(auth_path, "w") as f:
76
81
  json.dump(response, f)
77
82
 
78
- async def get_book_isbn(self, book: Book, semaphore: asyncio.Semaphore) -> Union[str, None]:
83
+ async def get_book_isbn(self, book: Book, runtime: datetime, semaphore: asyncio.Semaphore) -> Book:
84
+ # runtime isn't used but get_book_isbn must follow the get_book method signature.
85
+
79
86
  title = book.title
80
87
 
81
88
  async with semaphore:
@@ -90,15 +97,25 @@ class LibroFM(Retailer):
90
97
  )
91
98
 
92
99
  for b in response["audiobook_collection"]["audiobooks"]:
93
- if title == b["title"] and book.normalized_authors == get_normalized_authors(b["authors"]):
100
+ normalized_authors = get_normalized_authors(b["authors"])
101
+
102
+ if (
103
+ title == b["title"]
104
+ and is_matching_authors(book.normalized_authors, normalized_authors)
105
+ ):
94
106
  book.audiobook_isbn = b["isbn"]
95
- return book.audiobook_isbn
107
+ break
96
108
 
97
- return None
109
+ return book
98
110
 
99
111
  async def get_book(
100
112
  self, target: Book, runtime: datetime, semaphore: asyncio.Semaphore
101
113
  ) -> Book:
114
+ if target.format == BookFormat.AUDIOBOOK and not target.audiobook_isbn:
115
+ # When "format" is AUDIOBOOK here that means the target was pulled from an audiobook retailer wishlist
116
+ # In this flow, there is no attempt to resolve the isbn ahead of time, so it's done here instead.
117
+ await self.get_book_isbn(target, runtime, semaphore)
118
+
102
119
  if not target.audiobook_isbn:
103
120
  return Book(
104
121
  retailer=self.name,
@@ -122,7 +139,7 @@ class LibroFM(Retailer):
122
139
  retailer=self.name,
123
140
  title=target.title,
124
141
  authors=target.authors,
125
- list_price=0,
142
+ list_price=target.audiobook_list_price,
126
143
  current_price=currency_to_float(response["data"]["purchase_info"]["price"]),
127
144
  timepoint=runtime,
128
145
  format=BookFormat.AUDIOBOOK,
@@ -138,3 +155,68 @@ class LibroFM(Retailer):
138
155
  format=BookFormat.AUDIOBOOK,
139
156
  exists=False,
140
157
  )
158
+
159
+ async def get_wishlist(self, config: Config) -> list[Book]:
160
+ wishlist_books = []
161
+
162
+ page = 1
163
+ total_pages = 1
164
+ while page <= total_pages:
165
+ response = await self.make_request(
166
+ f"api/v10/explore/wishlist",
167
+ "GET",
168
+ params=dict(page=page)
169
+ )
170
+ wishlist = response.get("data", {}).get("wishlist", {})
171
+ if not wishlist:
172
+ return []
173
+
174
+ for book in wishlist.get("audiobooks", []):
175
+ wishlist_books.append(
176
+ Book(
177
+ retailer=self.name,
178
+ title=book["title"],
179
+ authors=", ".join(book["authors"]),
180
+ list_price=1,
181
+ current_price=1,
182
+ timepoint=config.run_time,
183
+ format=self.format,
184
+ audiobook_isbn=book["isbn"],
185
+ )
186
+ )
187
+
188
+ page += 1
189
+ total_pages = wishlist["total_pages"]
190
+
191
+ return wishlist_books
192
+
193
+ async def get_library(self, config: Config) -> list[Book]:
194
+ library_books = []
195
+
196
+ page = 1
197
+ total_pages = 1
198
+ while page <= total_pages:
199
+ response = await self.make_request(
200
+ f"api/v10/library",
201
+ "GET",
202
+ params=dict(page=page)
203
+ )
204
+
205
+ for book in response.get("audiobooks", []):
206
+ library_books.append(
207
+ Book(
208
+ retailer=self.name,
209
+ title=book["title"],
210
+ authors=", ".join(book["authors"]),
211
+ list_price=1,
212
+ current_price=1,
213
+ timepoint=config.run_time,
214
+ format=self.format,
215
+ audiobook_isbn=book["isbn"],
216
+ )
217
+ )
218
+
219
+ page += 1
220
+ total_pages = response["total_pages"]
221
+
222
+ return library_books
@@ -2,7 +2,8 @@ import abc
2
2
  import asyncio
3
3
  from datetime import datetime
4
4
 
5
- from tbr_deal_finder.book import Book
5
+ from tbr_deal_finder.book import Book, BookFormat
6
+ from tbr_deal_finder.config import Config
6
7
 
7
8
 
8
9
  class Retailer(abc.ABC):
@@ -12,6 +13,21 @@ class Retailer(abc.ABC):
12
13
  def name(self) -> str:
13
14
  raise NotImplementedError
14
15
 
16
+ @property
17
+ def format(self) -> BookFormat:
18
+ """The format of the books they sell.
19
+
20
+ For example,
21
+ Audible would be audiobooks
22
+ Kindle would be ebooks
23
+
24
+ :return:
25
+ """
26
+ raise NotImplementedError
27
+
28
+ async def set_auth(self):
29
+ raise NotImplementedError
30
+
15
31
  async def get_book(
16
32
  self, target: Book, runtime: datetime, semaphore: asyncio.Semaphore
17
33
  ) -> Book:
@@ -31,7 +47,9 @@ class Retailer(abc.ABC):
31
47
  """
32
48
  raise NotImplementedError
33
49
 
34
- async def set_auth(self):
50
+ async def get_wishlist(self, config: Config) -> list[Book]:
35
51
  raise NotImplementedError
36
52
 
53
+ async def get_library(self, config: Config) -> list[Book]:
54
+ raise NotImplementedError
37
55
 
@@ -6,9 +6,10 @@ import click
6
6
  import pandas as pd
7
7
  from tqdm.asyncio import tqdm_asyncio
8
8
 
9
- from tbr_deal_finder.book import Book, get_active_deals
9
+ from tbr_deal_finder.book import Book, get_active_deals, BookFormat
10
10
  from tbr_deal_finder.config import Config
11
- from tbr_deal_finder.library_exports import get_tbr_books
11
+ from tbr_deal_finder.owned_books import get_owned_books
12
+ from tbr_deal_finder.tracked_books import get_tbr_books
12
13
  from tbr_deal_finder.retailer import RETAILER_MAP
13
14
  from tbr_deal_finder.retailer.models import Retailer
14
15
  from tbr_deal_finder.utils import get_duckdb_conn, echo_warning, echo_info
@@ -41,7 +42,7 @@ def update_retailer_deal_table(config: Config, new_deals: list[Book]):
41
42
  # Any remaining values in active_deal_map mean that
42
43
  # it wasn't found and should be marked for deletion
43
44
  for deal in active_deal_map.values():
44
- echo_warning(f"{str(deal)} is no longer active")
45
+ echo_warning(f"{str(deal)} is no longer active\n")
45
46
  deal.timepoint = config.run_time
46
47
  deal.deleted = True
47
48
  df_data.append(deal.dict())
@@ -55,25 +56,10 @@ def update_retailer_deal_table(config: Config, new_deals: list[Book]):
55
56
  db_conn.unregister("_df")
56
57
 
57
58
 
58
- def _retry_books(found_books: list[Book], all_books: list[Book]) -> list[Book]:
59
- response = []
60
- found_book_set = {f'{b.title} - {b.authors}' for b in found_books}
61
- for book in all_books:
62
- if ":" not in book.title:
63
- continue
64
-
65
- if f'{book.title} - {book.authors}' not in found_book_set:
66
- alt_book = copy.deepcopy(book)
67
- alt_book.title = alt_book.title.split(":")[0]
68
- response.append(alt_book)
69
-
70
- return response
71
-
72
-
73
59
  async def _get_books(config, retailer: Retailer, books: list[Book]) -> list[Book]:
74
60
  """Get Books with limited concurrency.
75
61
 
76
- - Creates a semaphore to limit concurrent requests.
62
+ - Creates semaphore to limit concurrent requests.
77
63
  - Creates a list to store the response.
78
64
  - Creates a list to store unresolved books.
79
65
 
@@ -100,13 +86,9 @@ async def _get_books(config, retailer: Retailer, books: list[Book]) -> list[Book
100
86
  elif not book.exists:
101
87
  unresolved_books.append(book)
102
88
 
103
- if retry_books := _retry_books(response, books):
104
- echo_info("Attempting to find missing books with alternate title")
105
- response.extend(await _get_books(config, retailer, retry_books))
106
- elif unresolved_books:
107
- click.echo()
108
- for book in unresolved_books:
109
- echo_info(f"{book.title} by {book.authors} not found")
89
+ click.echo()
90
+ for book in unresolved_books:
91
+ echo_info(f"{book.title} by {book.authors} not found")
110
92
 
111
93
  return response
112
94
 
@@ -145,6 +127,34 @@ def _apply_proper_list_prices(books: list[Book]):
145
127
  book.list_price = max(book.current_price, list_price)
146
128
 
147
129
 
130
+ def _get_retailer_relevant_tbr_books(
131
+ retailer: Retailer,
132
+ books: list[Book],
133
+ owned_book_title_map: dict[str, dict[BookFormat, Book]],
134
+ ) -> list[Book]:
135
+ """
136
+ Don't check on deals in a specified format that does not match the format the retailer sells.
137
+ Also, don't check on deals for a book if a copy is already owned in that same format.
138
+
139
+ :param retailer:
140
+ :param books:
141
+ :param owned_book_title_map:
142
+ :return:
143
+ """
144
+
145
+ response = []
146
+
147
+ for book in books:
148
+ owned_versions = owned_book_title_map[book.full_title_str]
149
+ if (
150
+ (book.format == BookFormat.NA or book.format == retailer.format)
151
+ and retailer.format not in owned_versions
152
+ ):
153
+ response.append(book)
154
+
155
+ return response
156
+
157
+
148
158
  async def get_latest_deals(config: Config):
149
159
  """
150
160
  Fetches the latest book deals from all tracked retailers for the user's TBR list.
@@ -163,14 +173,26 @@ async def get_latest_deals(config: Config):
163
173
  """
164
174
 
165
175
  books: list[Book] = []
166
- tbr_books = get_tbr_books(config)
176
+ tbr_books = await get_tbr_books(config)
177
+ owned_books = await get_owned_books(config)
178
+
179
+ owned_book_title_map: dict[str, dict[BookFormat, Book]] = defaultdict(dict)
180
+ for book in owned_books:
181
+ owned_book_title_map[book.full_title_str][book.format] = book
182
+
167
183
  for retailer_str in config.tracked_retailers:
168
184
  retailer = RETAILER_MAP[retailer_str]()
169
185
  await retailer.set_auth()
170
186
 
187
+ relevant_tbr_books = _get_retailer_relevant_tbr_books(
188
+ retailer,
189
+ tbr_books,
190
+ owned_book_title_map,
191
+ )
192
+
171
193
  echo_info(f"Getting deals from {retailer.name}")
172
194
  click.echo("\n---------------")
173
- books.extend(await _get_books(config, retailer, tbr_books))
195
+ books.extend(await _get_books(config, retailer, relevant_tbr_books))
174
196
  click.echo("---------------\n")
175
197
 
176
198
  _apply_proper_list_prices(books)
@@ -0,0 +1,120 @@
1
+ import asyncio
2
+ import csv
3
+
4
+ from tqdm.asyncio import tqdm_asyncio
5
+
6
+ from tbr_deal_finder.book import Book, BookFormat, get_title_id
7
+ from tbr_deal_finder.retailer import Chirp, RETAILER_MAP
8
+ from tbr_deal_finder.config import Config
9
+ from tbr_deal_finder.library_exports import (
10
+ get_book_authors,
11
+ get_book_title,
12
+ is_tbr_book,
13
+ requires_audiobook_list_price_default
14
+ )
15
+ from tbr_deal_finder.retailer.models import Retailer
16
+ from tbr_deal_finder.utils import currency_to_float
17
+
18
+
19
+ def _library_export_tbr_books(config: Config, tbr_book_map: dict[str: Book]):
20
+ """Adds tbr books in the library export to the provided tbr_book_map
21
+
22
+ :param config:
23
+ :param tbr_book_map:
24
+ :return:
25
+ """
26
+ for library_export_path in config.library_export_paths:
27
+
28
+ with open(library_export_path, 'r', newline='', encoding='utf-8') as file:
29
+ # Use csv.DictReader to get dictionaries with column headers
30
+ for book_dict in csv.DictReader(file):
31
+ if not is_tbr_book(book_dict):
32
+ continue
33
+
34
+ title = get_book_title(book_dict)
35
+ authors = get_book_authors(book_dict)
36
+
37
+ key = get_title_id(title, authors, BookFormat.NA)
38
+ if key in tbr_book_map and tbr_book_map[key].audiobook_isbn:
39
+ continue
40
+
41
+ tbr_book_map[key] = Book(
42
+ retailer="N/A",
43
+ title=title,
44
+ authors=authors,
45
+ list_price=0,
46
+ current_price=0,
47
+ timepoint=config.run_time,
48
+ format=BookFormat.NA,
49
+ audiobook_isbn=book_dict.get("audiobook_isbn"),
50
+ audiobook_list_price=currency_to_float(book_dict.get("audiobook_list_price") or 0),
51
+ )
52
+
53
+
54
+ async def _apply_dynamic_audiobook_list_price(config: Config, tbr_book_map: dict[str: Book]):
55
+ target_books = [
56
+ book
57
+ for book in tbr_book_map.values()
58
+ if book.format == BookFormat.AUDIOBOOK
59
+ ]
60
+ if not target_books:
61
+ return
62
+
63
+ chirp = Chirp()
64
+ semaphore = asyncio.Semaphore(5)
65
+ books_with_pricing: list[Book] = await tqdm_asyncio.gather(
66
+ *[
67
+ chirp.get_book(book, config.run_time, semaphore)
68
+ for book in target_books
69
+ ],
70
+ desc=f"Getting list prices for Libro.FM wishlist"
71
+ )
72
+ for book in books_with_pricing:
73
+ tbr_book_map[book.title_id].audiobook_list_price = book.list_price
74
+
75
+
76
+ async def _retailer_wishlist(config: Config, tbr_book_map: dict[str: Book]):
77
+ """Adds wishlist books in the library export to the provided tbr_book_map
78
+ Books added here has the format the retailer sells (e.g. Audiobook)
79
+ so deals are only checked for retailers with that type.
80
+
81
+ For example,
82
+ I as a user have Dune on my audible wishlist.
83
+ I want to see deals for it on Libro because it's an audiobook.
84
+ I don't want to see Kindle deals.
85
+
86
+ :param config:
87
+ :param tbr_book_map:
88
+ :return:
89
+ """
90
+ for retailer_str in config.tracked_retailers:
91
+ retailer: Retailer = RETAILER_MAP[retailer_str]()
92
+ await retailer.set_auth()
93
+
94
+ for book in (await retailer.get_wishlist(config)):
95
+ na_key = get_title_id(book.title, book.authors, BookFormat.NA)
96
+ if na_key in tbr_book_map:
97
+ continue
98
+
99
+ key = book.title_id
100
+ if key in tbr_book_map and tbr_book_map[key].audiobook_isbn:
101
+ continue
102
+
103
+ tbr_book_map[key] = book
104
+
105
+ if requires_audiobook_list_price_default(config):
106
+ await _apply_dynamic_audiobook_list_price(config, tbr_book_map)
107
+
108
+
109
+ async def get_tbr_books(config: Config) -> list[Book]:
110
+ tbr_book_map: dict[str: Book] = {}
111
+
112
+ # Get TBRs specified in the user library (StoryGraph/GoodReads) export
113
+ _library_export_tbr_books(config, tbr_book_map)
114
+
115
+ # Pull wishlist from tracked retailers
116
+ await _retailer_wishlist(config, tbr_book_map)
117
+
118
+ return list(tbr_book_map.values())
119
+
120
+
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tbr-deal-finder
3
- Version: 0.1.5
3
+ Version: 0.1.7
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
@@ -9,6 +9,7 @@ Requires-Dist: aiohttp>=3.12.14
9
9
  Requires-Dist: audible==0.8.2
10
10
  Requires-Dist: click>=8.2.1
11
11
  Requires-Dist: duckdb>=1.3.2
12
+ Requires-Dist: levenshtein>=0.27.1
12
13
  Requires-Dist: pandas>=2.3.1
13
14
  Requires-Dist: questionary>=2.1.0
14
15
  Requires-Dist: tqdm>=4.67.1
@@ -17,15 +18,17 @@ Description-Content-Type: text/markdown
17
18
 
18
19
  # tbr-deal-finder
19
20
 
20
- Track price drops and find deals on books in your TBR (To Be Read) list across audiobook and ebook formats.
21
+ Track price drops and find deals on books in your TBR (To Be Read) and wishlist across digital book retailers.
21
22
 
22
23
  ## Features
23
- - Uses your StoryGraph exports, Goodreads exports, and custom csvs (spreadsheet) to track book deals
24
- - Supports multiple of the library exports above
24
+ - Use your StoryGraph exports, Goodreads exports, and custom csvs (spreadsheet) to track book deals
25
+ - Supports multiple of the library exports above
26
+ - Tracks deals on the wishlist of all your configured retailers like audible
25
27
  - Supports multiple locales and currencies
26
- - Finds the latest and active deals from supported sellers
28
+ - Find the latest and active deals from supported sellers
27
29
  - Simple CLI interface for setup and usage
28
30
  - Only get notified for new deals or view all active deals
31
+ - Filters out books you already own to prevent purchasing the same book on multiple retailers
29
32
 
30
33
  ## Support
31
34
 
@@ -66,7 +69,7 @@ Track price drops and find deals on books in your TBR (To Be Read) list across a
66
69
  https://docs.astral.sh/uv/getting-started/installation/
67
70
 
68
71
  ## Configuration
69
- This tool relies on the csv generated by the app you use to track your TBRs.
72
+ This tool can use the csv generated by the app you use to track your TBRs.
70
73
  Here are the steps to get your export.
71
74
 
72
75
  ### StoryGraph