tbr-deal-finder 0.1.6__py3-none-any.whl → 0.1.8__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.
@@ -2,6 +2,7 @@ import asyncio
2
2
  import json
3
3
  import os
4
4
  from datetime import datetime, timedelta
5
+ from textwrap import dedent
5
6
 
6
7
  import aiohttp
7
8
  import click
@@ -9,7 +10,7 @@ import click
9
10
  from tbr_deal_finder import TBR_DEALS_PATH
10
11
  from tbr_deal_finder.config import Config
11
12
  from tbr_deal_finder.retailer.models import Retailer
12
- 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
13
14
  from tbr_deal_finder.utils import currency_to_float, echo_err
14
15
 
15
16
 
@@ -83,10 +84,9 @@ class Chirp(Retailer):
83
84
  json.dump(response, f)
84
85
 
85
86
  async def get_book(
86
- self, target: Book, runtime: datetime, semaphore: asyncio.Semaphore
87
+ self, target: Book, semaphore: asyncio.Semaphore
87
88
  ) -> Book:
88
89
  title = target.title
89
- authors = target.authors
90
90
  async with semaphore:
91
91
  async with aiohttp.ClientSession() as http_client:
92
92
  response = await http_client.request(
@@ -102,16 +102,8 @@ class Chirp(Retailer):
102
102
 
103
103
  audiobooks = response_body["data"]["audiobooks"]["objects"]
104
104
  if not audiobooks:
105
- return Book(
106
- retailer=self.name,
107
- title=title,
108
- authors=authors,
109
- list_price=0,
110
- current_price=0,
111
- timepoint=runtime,
112
- format=BookFormat.AUDIOBOOK,
113
- exists=False,
114
- )
105
+ target.exists = False
106
+ return target
115
107
 
116
108
  for book in audiobooks:
117
109
  if not book["currentProduct"]:
@@ -120,28 +112,14 @@ class Chirp(Retailer):
120
112
  normalized_authors = get_normalized_authors([author["name"] for author in book["allAuthors"]])
121
113
  if (
122
114
  book["displayTitle"] == title
123
- and any(author in normalized_authors for author in target.normalized_authors)
115
+ and is_matching_authors(target.normalized_authors, normalized_authors)
124
116
  ):
125
- return Book(
126
- retailer=self.name,
127
- title=title,
128
- authors=authors,
129
- list_price=currency_to_float(book["currentProduct"]["listingPrice"]),
130
- current_price=currency_to_float(book["currentProduct"]["discountPrice"]),
131
- timepoint=runtime,
132
- format=BookFormat.AUDIOBOOK,
133
- )
117
+ target.list_price = currency_to_float(book["currentProduct"]["listingPrice"])
118
+ target.current_price = currency_to_float(book["currentProduct"]["discountPrice"])
119
+ return target
134
120
 
135
- return Book(
136
- retailer=self.name,
137
- title=title,
138
- authors=target.authors,
139
- list_price=0,
140
- current_price=0,
141
- timepoint=runtime,
142
- format=BookFormat.AUDIOBOOK,
143
- exists=False,
144
- )
121
+ target.exists = False
122
+ return target
145
123
 
146
124
  async def get_wishlist(self, config: Config) -> list[Book]:
147
125
  wishlist_books = []
@@ -180,3 +158,61 @@ class Chirp(Retailer):
180
158
  )
181
159
 
182
160
  page += 1
161
+
162
+ async def get_library(self, config: Config) -> list[Book]:
163
+ library_books = []
164
+ page = 1
165
+ query = dedent("""
166
+ query AndroidCurrentUserAudiobooks($page: Int!, $pageSize: Int!) {
167
+ currentUserAudiobooks(page: $page, pageSize: $pageSize, sort: TITLE_A_Z, clientCapabilities: [CHIRP_AUDIO]) {
168
+ audiobook {
169
+ id
170
+ allAuthors{name}
171
+ displayTitle
172
+ displayAuthors
173
+ displayNarrators
174
+ durationMs
175
+ description
176
+ publisher
177
+ }
178
+ archived
179
+ playable
180
+ finishedAt
181
+ currentOverallOffsetMs
182
+ }
183
+ }
184
+ """)
185
+
186
+ while True:
187
+ response = await self.make_request(
188
+ "POST",
189
+ json={
190
+ "query": query,
191
+ "variables": {"page": page, "pageSize": 15},
192
+ "operationName": "AndroidCurrentUserAudiobooks"
193
+ }
194
+ )
195
+
196
+ audiobooks = response.get(
197
+ "data", {}
198
+ ).get("currentUserAudiobooks", [])
199
+
200
+ if not audiobooks:
201
+ return library_books
202
+
203
+ for book in audiobooks:
204
+ audiobook = book["audiobook"]
205
+ authors = [author["name"] for author in audiobook["allAuthors"]]
206
+ library_books.append(
207
+ Book(
208
+ retailer=self.name,
209
+ title=audiobook["displayTitle"],
210
+ authors=", ".join(authors),
211
+ list_price=1,
212
+ current_price=1,
213
+ timepoint=config.run_time,
214
+ format=self.format,
215
+ )
216
+ )
217
+
218
+ page += 1
@@ -10,7 +10,7 @@ import click
10
10
  from tbr_deal_finder import TBR_DEALS_PATH
11
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
 
@@ -76,11 +76,11 @@ class LibroFM(Retailer):
76
76
  "password": click.prompt("Libro FM Password", hide_input=True),
77
77
  }
78
78
  )
79
- self.auth_token = response
79
+ self.auth_token = response["access_token"]
80
80
  with open(auth_path, "w") as f:
81
81
  json.dump(response, f)
82
82
 
83
- async def get_book_isbn(self, book: Book, runtime: datetime, semaphore: asyncio.Semaphore) -> Book:
83
+ async def get_book_isbn(self, book: Book, semaphore: asyncio.Semaphore) -> Book:
84
84
  # runtime isn't used but get_book_isbn must follow the get_book method signature.
85
85
 
86
86
  title = book.title
@@ -101,7 +101,7 @@ class LibroFM(Retailer):
101
101
 
102
102
  if (
103
103
  title == b["title"]
104
- and any(author in normalized_authors for author in book.normalized_authors)
104
+ and is_matching_authors(book.normalized_authors, normalized_authors)
105
105
  ):
106
106
  book.audiobook_isbn = b["isbn"]
107
107
  break
@@ -109,24 +109,11 @@ class LibroFM(Retailer):
109
109
  return book
110
110
 
111
111
  async def get_book(
112
- self, target: Book, runtime: datetime, semaphore: asyncio.Semaphore
112
+ self, target: Book, semaphore: asyncio.Semaphore
113
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
-
119
114
  if not target.audiobook_isbn:
120
- return Book(
121
- retailer=self.name,
122
- title=target.title,
123
- authors=target.authors,
124
- list_price=0,
125
- current_price=0,
126
- timepoint=runtime,
127
- format=BookFormat.AUDIOBOOK,
128
- exists=False,
129
- )
115
+ target.exists = False
116
+ return target
130
117
 
131
118
  async with semaphore:
132
119
  response = await self.make_request(
@@ -135,26 +122,12 @@ class LibroFM(Retailer):
135
122
  )
136
123
 
137
124
  if response:
138
- return Book(
139
- retailer=self.name,
140
- title=target.title,
141
- authors=target.authors,
142
- list_price=target.audiobook_list_price,
143
- current_price=currency_to_float(response["data"]["purchase_info"]["price"]),
144
- timepoint=runtime,
145
- format=BookFormat.AUDIOBOOK,
146
- )
125
+ target.list_price = target.audiobook_list_price
126
+ target.current_price = currency_to_float(response["data"]["purchase_info"]["price"])
127
+ return target
147
128
 
148
- return Book(
149
- retailer=self.name,
150
- title=target.title,
151
- authors=target.authors,
152
- list_price=0,
153
- current_price=0,
154
- timepoint=runtime,
155
- format=BookFormat.AUDIOBOOK,
156
- exists=False,
157
- )
129
+ target.exists = False
130
+ return target
158
131
 
159
132
  async def get_wishlist(self, config: Config) -> list[Book]:
160
133
  wishlist_books = []
@@ -165,7 +138,7 @@ class LibroFM(Retailer):
165
138
  response = await self.make_request(
166
139
  f"api/v10/explore/wishlist",
167
140
  "GET",
168
- params=dict(page=2)
141
+ params=dict(page=page)
169
142
  )
170
143
  wishlist = response.get("data", {}).get("wishlist", {})
171
144
  if not wishlist:
@@ -189,3 +162,34 @@ class LibroFM(Retailer):
189
162
  total_pages = wishlist["total_pages"]
190
163
 
191
164
  return wishlist_books
165
+
166
+ async def get_library(self, config: Config) -> list[Book]:
167
+ library_books = []
168
+
169
+ page = 1
170
+ total_pages = 1
171
+ while page <= total_pages:
172
+ response = await self.make_request(
173
+ f"api/v10/library",
174
+ "GET",
175
+ params=dict(page=page)
176
+ )
177
+
178
+ for book in response.get("audiobooks", []):
179
+ library_books.append(
180
+ Book(
181
+ retailer=self.name,
182
+ title=book["title"],
183
+ authors=", ".join(book["authors"]),
184
+ list_price=1,
185
+ current_price=1,
186
+ timepoint=config.run_time,
187
+ format=self.format,
188
+ audiobook_isbn=book["isbn"],
189
+ )
190
+ )
191
+
192
+ page += 1
193
+ total_pages = response["total_pages"]
194
+
195
+ return library_books
@@ -1,6 +1,5 @@
1
1
  import abc
2
2
  import asyncio
3
- from datetime import datetime
4
3
 
5
4
  from tbr_deal_finder.book import Book, BookFormat
6
5
  from tbr_deal_finder.config import Config
@@ -29,7 +28,7 @@ class Retailer(abc.ABC):
29
28
  raise NotImplementedError
30
29
 
31
30
  async def get_book(
32
- self, target: Book, runtime: datetime, semaphore: asyncio.Semaphore
31
+ self, target: Book, semaphore: asyncio.Semaphore
33
32
  ) -> Book:
34
33
  """Get book information from the retailer.
35
34
 
@@ -50,4 +49,6 @@ class Retailer(abc.ABC):
50
49
  async def get_wishlist(self, config: Config) -> list[Book]:
51
50
  raise NotImplementedError
52
51
 
52
+ async def get_library(self, config: Config) -> list[Book]:
53
+ raise NotImplementedError
53
54
 
@@ -21,9 +21,9 @@ def update_retailer_deal_table(config: Config, new_deals: list[Book]):
21
21
  :param new_deals:
22
22
  """
23
23
 
24
- # This could be done using a temp table for the new deals, but that feels like overkill
25
- # I can't imagine there's ever going to be more than 5,000 books in someone's TBR
26
- # If it were any larger we'd have bigger problems.
24
+ # This could be done using a temp table for the new deals, but that feels like overkill.
25
+ # I can't imagine there's ever going to be more than 5,000 books in someone's TBR.
26
+ # If it were any larger, we'd have bigger problems.
27
27
  active_deal_map = {deal.deal_id: deal for deal in get_active_deals()}
28
28
  # Dirty trick to ensure uniqueness in request
29
29
  new_deals = list({nd.deal_id: nd for nd in new_deals}.values())
@@ -41,7 +41,7 @@ def update_retailer_deal_table(config: Config, new_deals: list[Book]):
41
41
  # Any remaining values in active_deal_map mean that
42
42
  # it wasn't found and should be marked for deletion
43
43
  for deal in active_deal_map.values():
44
- echo_warning(f"{str(deal)} is no longer active")
44
+ echo_warning(f"{str(deal)} is no longer active\n")
45
45
  deal.timepoint = config.run_time
46
46
  deal.deleted = True
47
47
  df_data.append(deal.dict())
@@ -55,21 +55,6 @@ def update_retailer_deal_table(config: Config, new_deals: list[Book]):
55
55
  db_conn.unregister("_df")
56
56
 
57
57
 
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
58
  async def _get_books(config, retailer: Retailer, books: list[Book]) -> list[Book]:
74
59
  """Get Books with limited concurrency.
75
60
 
@@ -88,9 +73,13 @@ async def _get_books(config, retailer: Retailer, books: list[Book]) -> list[Book
88
73
  semaphore = asyncio.Semaphore(10)
89
74
  response = []
90
75
  unresolved_books = []
76
+ books = [copy.deepcopy(book) for book in books]
77
+ for book in books:
78
+ book.retailer = retailer.name
79
+ book.format = retailer.format
91
80
 
92
81
  tasks = [
93
- retailer.get_book(copy.deepcopy(book), config.run_time, semaphore)
82
+ retailer.get_book(book, semaphore)
94
83
  for book in books
95
84
  ]
96
85
  results = await tqdm_asyncio.gather(*tasks, desc=f"Getting latest prices from {retailer.name}")
@@ -100,13 +89,9 @@ async def _get_books(config, retailer: Retailer, books: list[Book]) -> list[Book
100
89
  elif not book.exists:
101
90
  unresolved_books.append(book)
102
91
 
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")
92
+ click.echo()
93
+ for book in unresolved_books:
94
+ echo_info(f"{book.title} by {book.authors} not found")
110
95
 
111
96
  return response
112
97
 
@@ -145,6 +130,27 @@ def _apply_proper_list_prices(books: list[Book]):
145
130
  book.list_price = max(book.current_price, list_price)
146
131
 
147
132
 
133
+ def _get_retailer_relevant_tbr_books(
134
+ retailer: Retailer,
135
+ books: list[Book],
136
+ ) -> list[Book]:
137
+ """
138
+ Don't check on deals in a specified format that does not match the format the retailer sells.
139
+
140
+ :param retailer:
141
+ :param books:
142
+ :return:
143
+ """
144
+
145
+ response = []
146
+
147
+ for book in books:
148
+ if book.format == BookFormat.NA or book.format == retailer.format:
149
+ response.append(book)
150
+
151
+ return response
152
+
153
+
148
154
  async def get_latest_deals(config: Config):
149
155
  """
150
156
  Fetches the latest book deals from all tracked retailers for the user's TBR list.
@@ -169,13 +175,10 @@ async def get_latest_deals(config: Config):
169
175
  retailer = RETAILER_MAP[retailer_str]()
170
176
  await retailer.set_auth()
171
177
 
172
- # Don't check on deals in a specified format
173
- # that does not match the format the retailer sells
174
- relevant_tbr_books = [
175
- book
176
- for book in tbr_books
177
- if book.format == BookFormat.NA or book.format == retailer.format
178
- ]
178
+ relevant_tbr_books = _get_retailer_relevant_tbr_books(
179
+ retailer,
180
+ tbr_books,
181
+ )
179
182
 
180
183
  echo_info(f"Getting deals from {retailer.name}")
181
184
  click.echo("\n---------------")