tbr-deal-finder 0.1.4__py3-none-any.whl → 0.1.6__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/book.py CHANGED
@@ -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}"
tbr_deal_finder/cli.py CHANGED
@@ -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__':
@@ -2,15 +2,17 @@ import asyncio
2
2
  import csv
3
3
  import shutil
4
4
  import tempfile
5
+ from datetime import datetime
6
+ from typing import Callable, Awaitable, Optional
5
7
 
6
8
  from tqdm.asyncio import tqdm_asyncio
7
9
 
8
- from tbr_deal_finder.book import Book, BookFormat
10
+ from tbr_deal_finder.book import Book, BookFormat, get_normalized_authors
9
11
  from tbr_deal_finder.config import Config
10
- from tbr_deal_finder.retailer.librofm import LibroFM
12
+ from tbr_deal_finder.retailer import LibroFM, Chirp
11
13
 
12
14
 
13
- def _get_book_authors(book: dict) -> str:
15
+ def get_book_authors(book: dict) -> str:
14
16
  if authors := book.get('Authors'):
15
17
  return authors
16
18
 
@@ -21,12 +23,12 @@ def _get_book_authors(book: dict) -> str:
21
23
  return authors
22
24
 
23
25
 
24
- def _get_book_title(book: dict) -> str:
26
+ def get_book_title(book: dict) -> str:
25
27
  title = book['Title']
26
28
  return title.split("(")[0].strip()
27
29
 
28
30
 
29
- def _is_tbr_book(book: dict) -> bool:
31
+ def is_tbr_book(book: dict) -> bool:
30
32
  if "Read Status" in book:
31
33
  return book["Read Status"] == "to-read"
32
34
  elif "Bookshelves" in book:
@@ -35,65 +37,56 @@ def _is_tbr_book(book: dict) -> bool:
35
37
  return True
36
38
 
37
39
 
38
- def get_tbr_books(config: Config) -> list[Book]:
39
- tbr_book_map: dict[str: Book] = {}
40
- for library_export_path in config.library_export_paths:
41
-
42
- with open(library_export_path, 'r', newline='', encoding='utf-8') as file:
43
- # Use csv.DictReader to get dictionaries with column headers
44
- for book_dict in csv.DictReader(file):
45
- if not _is_tbr_book(book_dict):
46
- continue
47
-
48
- title = _get_book_title(book_dict)
49
- authors = _get_book_authors(book_dict)
50
- key = f'{title}__{authors}'
51
-
52
- if key in tbr_book_map:
53
- continue
54
-
55
- tbr_book_map[key] = Book(
56
- retailer="N/A",
57
- title=title,
58
- authors=authors,
59
- list_price=0,
60
- current_price=0,
61
- timepoint=config.run_time,
62
- format=BookFormat.NA,
63
- audiobook_isbn=book_dict["audiobook_isbn"],
64
- )
65
- return list(tbr_book_map.values())
66
-
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
+ )
67
46
 
68
- async def maybe_set_library_export_audiobook_isbn(config: Config):
69
- """To get the price from Libro.fm for a book you need its ISBN
70
47
 
71
- As opposed to trying to get that every time latest-deals is run
72
- we're just updating the export csv once to include the ISBN.
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:
73
63
  """
74
-
75
- if "Libro.FM" not in config.tracked_retailers:
76
- return
64
+ if not column_name:
65
+ column_name = attr_name
77
66
 
78
67
  books_requiring_check_map = dict()
79
- book_to_isbn_map = dict()
80
-
68
+ book_to_col_val_map = dict()
81
69
 
70
+ # Iterate all library export paths
82
71
  for library_export_path in config.library_export_paths:
83
72
  with open(library_export_path, 'r', newline='', encoding='utf-8') as file:
84
73
  # Use csv.DictReader to get dictionaries with column headers
85
74
  for book_dict in csv.DictReader(file):
86
- if not _is_tbr_book(book_dict):
75
+ if not is_tbr_book(book_dict):
87
76
  continue
88
77
 
89
- title = _get_book_title(book_dict)
90
- authors = _get_book_authors(book_dict)
91
- key = f'{title}__{authors}'
78
+ title = get_book_title(book_dict)
79
+ authors = get_book_authors(book_dict)
80
+ key = f'{title}__{get_normalized_authors(authors)}'
92
81
 
93
- if "audiobook_isbn" in book_dict:
94
- book_to_isbn_map[key] = book_dict["audiobook_isbn"]
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
95
87
  books_requiring_check_map.pop(key, None)
96
- elif key not in book_to_isbn_map:
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
97
90
  books_requiring_check_map[key] = Book(
98
91
  retailer="N/A",
99
92
  title=title,
@@ -105,27 +98,33 @@ async def maybe_set_library_export_audiobook_isbn(config: Config):
105
98
  )
106
99
 
107
100
  if not books_requiring_check_map:
101
+ # Everything was resolved, nothing else to do
108
102
  return
109
103
 
110
- libro_fm = LibroFM()
111
- # Setting it lower to be a good user of libro on their more expensive search call
112
- semaphore = asyncio.Semaphore(3)
104
+ semaphore = asyncio.Semaphore(5)
105
+ human_readable_name = attr_name.replace("_", " ").title()
113
106
 
114
- # Set the audiobook isbn for Book instances in books_requiring_check_map
115
- await tqdm_asyncio.gather(
107
+ # Get books with the appropriate transform applied
108
+ # Responsibility is on the callable here
109
+ enriched_books = await tqdm_asyncio.gather(
116
110
  *[
117
- libro_fm.get_book_isbn(book, semaphore) for book in books_requiring_check_map.values()
111
+ get_book_callable(book, config.run_time, semaphore) for book in books_requiring_check_map.values()
118
112
  ],
119
- desc="Getting required audiobook ISBN info"
113
+ desc=f"Getting required {human_readable_name} info"
120
114
  )
115
+ updated_book_map = {
116
+ b.full_title_str: b
117
+ for b in enriched_books
118
+ }
119
+
121
120
 
122
- # Go back and now add the audiobook_isbn
121
+ # Go back and now add the new column where it hasn't been set
123
122
  for library_export_path in config.library_export_paths:
124
123
  with open(library_export_path, 'r', newline='', encoding='utf-8') as file:
125
124
  reader = csv.DictReader(file)
126
- field_names = list(reader.fieldnames) + ["audiobook_isbn"]
125
+ field_names = list(reader.fieldnames) + [column_name]
127
126
  file_content = [book_dict for book_dict in reader]
128
- if not file_content or "audiobook_isbn" in file_content[0]:
127
+ if not file_content or column_name in file_content[0]:
129
128
  continue
130
129
 
131
130
  with tempfile.NamedTemporaryFile(mode='w', delete=False, newline='') as temp_file:
@@ -134,22 +133,73 @@ async def maybe_set_library_export_audiobook_isbn(config: Config):
134
133
  writer.writeheader()
135
134
 
136
135
  for book_dict in file_content:
137
- if _is_tbr_book(book_dict):
138
- title = _get_book_title(book_dict)
139
- authors = _get_book_authors(book_dict)
140
- key = f'{title}__{authors}'
141
-
142
- if key in book_to_isbn_map:
143
- audiobook_isbn = book_to_isbn_map[key]
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)
144
146
  else:
145
- book = books_requiring_check_map[key]
146
- audiobook_isbn = book.audiobook_isbn
147
+ col_val = ""
147
148
 
148
- book_dict["audiobook_isbn"] = audiobook_isbn
149
+ book_dict[column_name] = col_val
149
150
  else:
150
- book_dict["audiobook_isbn"] = ""
151
+ book_dict[column_name] = ""
151
152
 
152
153
  writer.writerow(book_dict)
153
154
 
154
155
  shutil.move(temp_filename, library_export_path)
155
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
@@ -1,38 +1,105 @@
1
1
  import asyncio
2
- from datetime import datetime
2
+ import json
3
+ import os
4
+ from datetime import datetime, timedelta
3
5
 
4
6
  import aiohttp
7
+ import click
5
8
 
9
+ from tbr_deal_finder import TBR_DEALS_PATH
10
+ from tbr_deal_finder.config import Config
6
11
  from tbr_deal_finder.retailer.models import Retailer
7
12
  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.utils import currency_to_float, echo_err
9
14
 
10
15
 
11
16
  class Chirp(Retailer):
12
17
  # Static because url for other locales just redirects to .com
13
- _url: str = "https://www.chirpbooks.com/api/graphql"
18
+ _url: str = "https://api.chirpbooks.com/api/graphql"
19
+ USER_AGENT = "ChirpBooks/5.13.9 (Android)"
20
+
21
+ def __init__(self):
22
+ self.auth_token = None
14
23
 
15
24
  @property
16
25
  def name(self) -> str:
17
26
  return "Chirp"
18
27
 
28
+ @property
29
+ def format(self) -> BookFormat:
30
+ return BookFormat.AUDIOBOOK
31
+
32
+ async def make_request(self, request_type: str, **kwargs) -> dict:
33
+ headers = kwargs.pop("headers", {})
34
+ headers["Accept"] = "application/json"
35
+ headers["Content-Type"] = "application/json"
36
+ headers["User-Agent"] = self.USER_AGENT
37
+ if self.auth_token:
38
+ headers["authorization"] = f"Bearer {self.auth_token}"
39
+
40
+ async with aiohttp.ClientSession() as http_client:
41
+ response = await http_client.request(
42
+ request_type.upper(),
43
+ self._url,
44
+ headers=headers,
45
+ **kwargs
46
+ )
47
+ if response.ok:
48
+ return await response.json()
49
+ else:
50
+ return {}
51
+
52
+ async def set_auth(self):
53
+ auth_path = TBR_DEALS_PATH.joinpath("chirp.json")
54
+ if os.path.exists(auth_path):
55
+ with open(auth_path, "r") as f:
56
+ auth_info = json.load(f)
57
+ if auth_info:
58
+ token_created_at = datetime.fromtimestamp(auth_info["created_at"])
59
+ max_token_age = datetime.now() - timedelta(days=5)
60
+ if token_created_at > max_token_age:
61
+ self.auth_token = auth_info["data"]["signIn"]["user"]["token"]
62
+ return
63
+
64
+ response = await self.make_request(
65
+ "POST",
66
+ json={
67
+ "query": "mutation signIn($email: String!, $password: String!) { signIn(email: $email, password: $password) { user { id token webToken email } } }",
68
+ "variables": {
69
+ "email": click.prompt("Chirp account email"),
70
+ "password": click.prompt("Chirp Password", hide_input=True),
71
+ }
72
+ }
73
+ )
74
+ if not response:
75
+ echo_err("Chirp login failed, please try again.")
76
+ await self.set_auth()
77
+
78
+ # Set token for future requests during the current execution
79
+ self.auth_token = response["data"]["signIn"]["user"]["token"]
80
+
81
+ response["created_at"] = datetime.now().timestamp()
82
+ with open(auth_path, "w") as f:
83
+ json.dump(response, f)
84
+
19
85
  async def get_book(
20
86
  self, target: Book, runtime: datetime, semaphore: asyncio.Semaphore
21
87
  ) -> Book:
22
88
  title = target.title
23
89
  authors = target.authors
90
+ async with semaphore:
91
+ async with aiohttp.ClientSession() as http_client:
92
+ response = await http_client.request(
93
+ "POST",
94
+ self._url,
95
+ json={
96
+ "query": "fragment audiobookFields on Audiobook{id averageRating coverUrl displayAuthors displayTitle ratingsCount url allAuthors{name slug url}} fragment audiobookWithShoppingCartAndUserAudiobookFields on Audiobook{...audiobookFields currentUserShoppingCartItem{id}currentUserWishlistItem{id}currentUserUserAudiobook{id}currentUserHasAuthorFollow{id}} fragment productFields on Product{discountPrice id isFreeListing listingPrice purchaseUrl savingsPercent showListingPrice timeLeft bannerType} query AudiobookSearch($query:String!,$promotionFilter:String,$filter:String,$page:Int,$pageSize:Int){audiobooks(query:$query,promotionFilter:$promotionFilter,filter:$filter,page:$page,pageSize:$pageSize){totalCount objects(page:$page,pageSize:$pageSize){... on Audiobook{...audiobookWithShoppingCartAndUserAudiobookFields futureSaleDate currentProduct{...productFields}}}}}",
97
+ "variables": {"query": title, "filter": "all", "page": 1, "promotionFilter": "default"},
98
+ "operationName": "AudiobookSearch"
99
+ }
100
+ )
101
+ response_body = await response.json()
24
102
 
25
- async with aiohttp.ClientSession() as http_client:
26
- response = await http_client.request(
27
- "POST",
28
- self._url,
29
- json={
30
- "query": "fragment audiobookFields on Audiobook{id averageRating coverUrl displayAuthors displayTitle ratingsCount url allAuthors{name slug url}} fragment audiobookWithShoppingCartAndUserAudiobookFields on Audiobook{...audiobookFields currentUserShoppingCartItem{id}currentUserWishlistItem{id}currentUserUserAudiobook{id}currentUserHasAuthorFollow{id}} fragment productFields on Product{discountPrice id isFreeListing listingPrice purchaseUrl savingsPercent showListingPrice timeLeft bannerType} query AudiobookSearch($query:String!,$promotionFilter:String,$filter:String,$page:Int,$pageSize:Int){audiobooks(query:$query,promotionFilter:$promotionFilter,filter:$filter,page:$page,pageSize:$pageSize){totalCount objects(page:$page,pageSize:$pageSize){... on Audiobook{...audiobookWithShoppingCartAndUserAudiobookFields futureSaleDate currentProduct{...productFields}}}}}",
31
- "variables": {"query": title, "filter": "all", "page": 1, "promotionFilter": "default"},
32
- "operationName": "AudiobookSearch"
33
- }
34
- )
35
- response_body = await response.json()
36
103
  audiobooks = response_body["data"]["audiobooks"]["objects"]
37
104
  if not audiobooks:
38
105
  return Book(
@@ -50,9 +117,10 @@ class Chirp(Retailer):
50
117
  if not book["currentProduct"]:
51
118
  continue
52
119
 
120
+ normalized_authors = get_normalized_authors([author["name"] for author in book["allAuthors"]])
53
121
  if (
54
122
  book["displayTitle"] == title
55
- and get_normalized_authors(book["displayAuthors"]) == target.normalized_authors
123
+ and any(author in normalized_authors for author in target.normalized_authors)
56
124
  ):
57
125
  return Book(
58
126
  retailer=self.name,
@@ -75,5 +143,40 @@ class Chirp(Retailer):
75
143
  exists=False,
76
144
  )
77
145
 
78
- async def set_auth(self):
79
- return
146
+ async def get_wishlist(self, config: Config) -> list[Book]:
147
+ wishlist_books = []
148
+ page = 1
149
+
150
+ while True:
151
+ response = await self.make_request(
152
+ "POST",
153
+ json={
154
+ "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}}}}}}}",
155
+ "variables": {"page": page, "pageSize": 15},
156
+ "operationName": "FetchWishlistDealAudiobooks"
157
+ }
158
+ )
159
+
160
+ audiobooks = response.get(
161
+ "data", {}
162
+ ).get("currentUserWishlist", {}).get("paginatedItems", {}).get("objects", [])
163
+
164
+ if not audiobooks:
165
+ return wishlist_books
166
+
167
+ for book in audiobooks:
168
+ audiobook = book["audiobook"]
169
+ authors = [author["name"] for author in audiobook["allAuthors"]]
170
+ wishlist_books.append(
171
+ Book(
172
+ retailer=self.name,
173
+ title=audiobook["displayTitle"],
174
+ authors=", ".join(authors),
175
+ list_price=1,
176
+ current_price=1,
177
+ timepoint=config.run_time,
178
+ format=self.format,
179
+ )
180
+ )
181
+
182
+ page += 1
@@ -3,12 +3,12 @@ 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
13
  from tbr_deal_finder.book import Book, BookFormat, get_normalized_authors
14
14
  from tbr_deal_finder.utils import currency_to_float
@@ -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,28 +80,42 @@ 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
- response = await self.make_request(
81
- f"api/v10/explore/search",
82
- "GET",
83
- params={
84
- "q": title,
85
- "searchby": "titles",
86
- "sortby": "relevance#results",
87
- },
88
- )
87
+
88
+ async with semaphore:
89
+ response = await self.make_request(
90
+ f"api/v10/explore/search",
91
+ "GET",
92
+ params={
93
+ "q": title,
94
+ "searchby": "titles",
95
+ "sortby": "relevance#results",
96
+ },
97
+ )
89
98
 
90
99
  for b in response["audiobook_collection"]["audiobooks"]:
91
- 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 any(author in normalized_authors for author in book.normalized_authors)
105
+ ):
92
106
  book.audiobook_isbn = b["isbn"]
93
- return book.audiobook_isbn
107
+ break
94
108
 
95
- return None
109
+ return book
96
110
 
97
111
  async def get_book(
98
112
  self, target: Book, runtime: datetime, semaphore: asyncio.Semaphore
99
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
+
100
119
  if not target.audiobook_isbn:
101
120
  return Book(
102
121
  retailer=self.name,
@@ -108,17 +127,19 @@ class LibroFM(Retailer):
108
127
  format=BookFormat.AUDIOBOOK,
109
128
  exists=False,
110
129
  )
111
- response = await self.make_request(
112
- f"api/v10/explore/audiobook_details/{target.audiobook_isbn}",
113
- "GET"
114
- )
130
+
131
+ async with semaphore:
132
+ response = await self.make_request(
133
+ f"api/v10/explore/audiobook_details/{target.audiobook_isbn}",
134
+ "GET"
135
+ )
115
136
 
116
137
  if response:
117
138
  return Book(
118
139
  retailer=self.name,
119
140
  title=target.title,
120
141
  authors=target.authors,
121
- list_price=0,
142
+ list_price=target.audiobook_list_price,
122
143
  current_price=currency_to_float(response["data"]["purchase_info"]["price"]),
123
144
  timepoint=runtime,
124
145
  format=BookFormat.AUDIOBOOK,
@@ -134,3 +155,37 @@ class LibroFM(Retailer):
134
155
  format=BookFormat.AUDIOBOOK,
135
156
  exists=False,
136
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=2)
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
@@ -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,7 @@ 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
 
37
53
 
@@ -6,12 +6,12 @@ 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.tracked_books import get_tbr_books
12
12
  from tbr_deal_finder.retailer import RETAILER_MAP
13
13
  from tbr_deal_finder.retailer.models import Retailer
14
- from tbr_deal_finder.utils import get_duckdb_conn
14
+ from tbr_deal_finder.utils import get_duckdb_conn, echo_warning, echo_info
15
15
 
16
16
 
17
17
  def update_retailer_deal_table(config: Config, new_deals: list[Book]):
@@ -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
- click.echo(f"{str(deal)} is no longer active")
44
+ echo_warning(f"{str(deal)} is no longer active")
45
45
  deal.timepoint = config.run_time
46
46
  deal.deleted = True
47
47
  df_data.append(deal.dict())
@@ -73,7 +73,7 @@ def _retry_books(found_books: list[Book], all_books: list[Book]) -> list[Book]:
73
73
  async def _get_books(config, retailer: Retailer, books: list[Book]) -> list[Book]:
74
74
  """Get Books with limited concurrency.
75
75
 
76
- - Creates a semaphore to limit concurrent requests.
76
+ - Creates semaphore to limit concurrent requests.
77
77
  - Creates a list to store the response.
78
78
  - Creates a list to store unresolved books.
79
79
 
@@ -101,12 +101,12 @@ async def _get_books(config, retailer: Retailer, books: list[Book]) -> list[Book
101
101
  unresolved_books.append(book)
102
102
 
103
103
  if retry_books := _retry_books(response, books):
104
- click.echo("Attempting to find missing books with alternate title")
104
+ echo_info("Attempting to find missing books with alternate title")
105
105
  response.extend(await _get_books(config, retailer, retry_books))
106
106
  elif unresolved_books:
107
107
  click.echo()
108
108
  for book in unresolved_books:
109
- click.echo(f"{book.title} by {book.authors} not found")
109
+ echo_info(f"{book.title} by {book.authors} not found")
110
110
 
111
111
  return response
112
112
 
@@ -163,14 +163,23 @@ async def get_latest_deals(config: Config):
163
163
  """
164
164
 
165
165
  books: list[Book] = []
166
- tbr_books = get_tbr_books(config)
166
+ tbr_books = await get_tbr_books(config)
167
+
167
168
  for retailer_str in config.tracked_retailers:
168
169
  retailer = RETAILER_MAP[retailer_str]()
169
170
  await retailer.set_auth()
170
171
 
171
- click.echo(f"Getting deals from {retailer.name}")
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
+ ]
179
+
180
+ echo_info(f"Getting deals from {retailer.name}")
172
181
  click.echo("\n---------------")
173
- books.extend(await _get_books(config, retailer, tbr_books))
182
+ books.extend(await _get_books(config, retailer, relevant_tbr_books))
174
183
  click.echo("---------------\n")
175
184
 
176
185
  _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
+
tbr_deal_finder/utils.py CHANGED
@@ -1,6 +1,7 @@
1
1
  import re
2
2
  from typing import Optional
3
3
 
4
+ import click
4
5
  import duckdb
5
6
 
6
7
  from tbr_deal_finder import TBR_DEALS_PATH, QUERY_PATH
@@ -38,3 +39,19 @@ def execute_query(
38
39
 
39
40
  def get_query_by_name(file_name: str) -> str:
40
41
  return QUERY_PATH.joinpath(file_name).read_text()
42
+
43
+
44
+ def echo_err(message):
45
+ click.secho(f'\n❌ {message}\n', fg='red', bold=True)
46
+
47
+
48
+ def echo_success(message):
49
+ click.secho(f'\n✅ {message}', fg='green', bold=True)
50
+
51
+
52
+ def echo_warning(message):
53
+ click.secho(f'\n⚠️ {message}', fg='yellow')
54
+
55
+
56
+ def echo_info(message):
57
+ click.secho(f'{message}', fg='blue')
@@ -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
@@ -0,0 +1,22 @@
1
+ tbr_deal_finder/__init__.py,sha256=WCoj0GZrRiCQlrpkLTw1VUeJmX-RtBLdLqnFYn1Es_4,208
2
+ tbr_deal_finder/book.py,sha256=ZnwuIU2-rP0UC12fSC_HiTXpmgBp5XrRiFe82OLQ-E0,3786
3
+ tbr_deal_finder/cli.py,sha256=jIwzyESLGc77Wyxsv4XjEfMHZHjQI_PTmBLamc9tiV0,7284
4
+ tbr_deal_finder/config.py,sha256=3fgN92sVsQbVqRBc58QK9w5t35zoPX6pP3k4nnJ_YTg,3441
5
+ tbr_deal_finder/library_exports.py,sha256=Hupx3mJyhvqXEslR2R3ifG9ykSKxkOb-H3gGK_CRx68,7133
6
+ tbr_deal_finder/migrations.py,sha256=6_WV55bm71UCFrcFrfJXlEX5uDrgnNTWZPq6vZTg18o,3733
7
+ tbr_deal_finder/retailer_deal.py,sha256=wMziFXCvrJQ_i4IdsXVPlaXnUIeGTRPt_rwbPfqX2FE,6742
8
+ tbr_deal_finder/tracked_books.py,sha256=EKecARSOMPPkmjSSWfWQw9B-TnoSd3-6AQepc2EYTz0,4031
9
+ tbr_deal_finder/utils.py,sha256=_4wdGFDtqCdMyoMnwTDiHgCR4WQLAcQr8LlZZZUcq6E,1357
10
+ tbr_deal_finder/queries/get_active_deals.sql,sha256=jILZK5UVNPLbbKWgqMW0brEZyCb9XBdQZJLHRULoQC4,195
11
+ tbr_deal_finder/queries/get_deals_found_at.sql,sha256=1vAE8PsAvfFi0SbvoUw8pvLwRN9VGYTJ7AVI3rmxXEI,122
12
+ tbr_deal_finder/queries/latest_deal_last_ran_most_recent_success.sql,sha256=W4cNMAHtcW2DzQyPL8SHHFcbVZQKVK2VfTzazxC3LJU,107
13
+ tbr_deal_finder/retailer/__init__.py,sha256=WePMSN7vi4EL_uPiAH6ogNNE-kRQe4OHT4CYGTKvBSk,243
14
+ tbr_deal_finder/retailer/audible.py,sha256=AY8jippIQe0XqCXk9iLb3CHADIYIG3orlctNQRSv2q8,5315
15
+ tbr_deal_finder/retailer/chirp.py,sha256=BVtHsrM0nsMmT2fxDnUliXVWfY2xZY8TR3FWZzyaxIA,8042
16
+ tbr_deal_finder/retailer/librofm.py,sha256=ZiowAIpDYnuH6KREdPK874t-Handlr0jZ9Mj0QMVGis,6428
17
+ tbr_deal_finder/retailer/models.py,sha256=wAGZtp0BWz9vlZCcWZqll8gwXUP6-6oFtsWv3gCXEHM,1415
18
+ tbr_deal_finder-0.1.6.dist-info/METADATA,sha256=CbRtsumWOcJ_VaabBoCqzGURzKjCOvvdk5koY11ukZE,4215
19
+ tbr_deal_finder-0.1.6.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
20
+ tbr_deal_finder-0.1.6.dist-info/entry_points.txt,sha256=y_KG1k8xVCY8gngSZ-na2bkK-tTLUdOc_qZ9Djwldv0,60
21
+ tbr_deal_finder-0.1.6.dist-info/licenses/LICENSE,sha256=rNc0wNPn4d4HHu6ZheJzeUaz_FbJ4rj2Dr2FjAivkNg,1064
22
+ tbr_deal_finder-0.1.6.dist-info/RECORD,,
@@ -1,21 +0,0 @@
1
- tbr_deal_finder/__init__.py,sha256=WCoj0GZrRiCQlrpkLTw1VUeJmX-RtBLdLqnFYn1Es_4,208
2
- tbr_deal_finder/book.py,sha256=2MQirkxDIVKdQQ07U56zwOw45rC8KH-5aC932_X9dhE,3333
3
- tbr_deal_finder/cli.py,sha256=8riqTK5QVPKyP3aUQ3hHjNbIuZ0ibF_WLiNFMBqddT4,6830
4
- tbr_deal_finder/config.py,sha256=3fgN92sVsQbVqRBc58QK9w5t35zoPX6pP3k4nnJ_YTg,3441
5
- tbr_deal_finder/library_exports.py,sha256=FJXBQV_9H5AI_cwNuGjvGQxgQ1RLlpldmDBwc_PsiK4,5474
6
- tbr_deal_finder/migrations.py,sha256=6_WV55bm71UCFrcFrfJXlEX5uDrgnNTWZPq6vZTg18o,3733
7
- tbr_deal_finder/retailer_deal.py,sha256=J3dGceB84wDQUV0FsW_0I0PLA5nU6LStzAx6sMNTo2c,6408
8
- tbr_deal_finder/utils.py,sha256=c_AfIpfE1IAewUBiaRhPHjBM2o-fvuVVcWfM7jPEOvk,1021
9
- tbr_deal_finder/queries/get_active_deals.sql,sha256=jILZK5UVNPLbbKWgqMW0brEZyCb9XBdQZJLHRULoQC4,195
10
- tbr_deal_finder/queries/get_deals_found_at.sql,sha256=1vAE8PsAvfFi0SbvoUw8pvLwRN9VGYTJ7AVI3rmxXEI,122
11
- tbr_deal_finder/queries/latest_deal_last_ran_most_recent_success.sql,sha256=W4cNMAHtcW2DzQyPL8SHHFcbVZQKVK2VfTzazxC3LJU,107
12
- tbr_deal_finder/retailer/__init__.py,sha256=WePMSN7vi4EL_uPiAH6ogNNE-kRQe4OHT4CYGTKvBSk,243
13
- tbr_deal_finder/retailer/audible.py,sha256=7z7rDQBcCwOhdATU4BJjsJ-QBP0HnxscMSGPmzN_K2k,4408
14
- tbr_deal_finder/retailer/chirp.py,sha256=mi2uIhiXHxMnlRgtd1BZULEZbpF_K2HzxWubV4v_vvc,3540
15
- tbr_deal_finder/retailer/librofm.py,sha256=M4WvGh3Gf3LVUE3KOCVtNKJB8koQasgybUFhKBvqBe0,4476
16
- tbr_deal_finder/retailer/models.py,sha256=zEwyM_0ildB8p38sxfpE6p2dIvtwAjeaul-GkJlk2Fo,1012
17
- tbr_deal_finder-0.1.4.dist-info/METADATA,sha256=oLSiX7Bb6Spb3DAAxB6cu0qMsWUfk8LnQgLtzGh76Ho,4157
18
- tbr_deal_finder-0.1.4.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
19
- tbr_deal_finder-0.1.4.dist-info/entry_points.txt,sha256=y_KG1k8xVCY8gngSZ-na2bkK-tTLUdOc_qZ9Djwldv0,60
20
- tbr_deal_finder-0.1.4.dist-info/licenses/LICENSE,sha256=rNc0wNPn4d4HHu6ZheJzeUaz_FbJ4rj2Dr2FjAivkNg,1064
21
- tbr_deal_finder-0.1.4.dist-info/RECORD,,