tbr-deal-finder 0.1.7__py3-none-any.whl → 0.2.0__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 +107 -56
- tbr_deal_finder/cli.py +5 -3
- tbr_deal_finder/config.py +10 -0
- tbr_deal_finder/migrations.py +16 -0
- tbr_deal_finder/queries/get_active_deals.sql +1 -1
- tbr_deal_finder/queries/get_deals_found_at.sql +1 -1
- tbr_deal_finder/retailer/__init__.py +2 -0
- tbr_deal_finder/retailer/amazon.py +85 -0
- tbr_deal_finder/retailer/audible.py +13 -100
- tbr_deal_finder/retailer/chirp.py +34 -55
- tbr_deal_finder/retailer/kindle.py +141 -0
- tbr_deal_finder/retailer/librofm.py +27 -53
- tbr_deal_finder/retailer/models.py +37 -2
- tbr_deal_finder/retailer_deal.py +9 -19
- tbr_deal_finder/tracked_books.py +258 -39
- {tbr_deal_finder-0.1.7.dist-info → tbr_deal_finder-0.2.0.dist-info}/METADATA +5 -2
- tbr_deal_finder-0.2.0.dist-info/RECORD +24 -0
- tbr_deal_finder/library_exports.py +0 -208
- tbr_deal_finder-0.1.7.dist-info/RECORD +0 -23
- {tbr_deal_finder-0.1.7.dist-info → tbr_deal_finder-0.2.0.dist-info}/WHEEL +0 -0
- {tbr_deal_finder-0.1.7.dist-info → tbr_deal_finder-0.2.0.dist-info}/entry_points.txt +0 -0
- {tbr_deal_finder-0.1.7.dist-info → tbr_deal_finder-0.2.0.dist-info}/licenses/LICENSE +0 -0
tbr_deal_finder/tracked_books.py
CHANGED
@@ -1,19 +1,18 @@
|
|
1
1
|
import asyncio
|
2
|
+
import copy
|
2
3
|
import csv
|
4
|
+
from collections import defaultdict
|
5
|
+
from typing import Callable, Awaitable, Optional
|
3
6
|
|
7
|
+
import pandas as pd
|
4
8
|
from tqdm.asyncio import tqdm_asyncio
|
5
9
|
|
6
10
|
from tbr_deal_finder.book import Book, BookFormat, get_title_id
|
7
|
-
from tbr_deal_finder.
|
11
|
+
from tbr_deal_finder.owned_books import get_owned_books
|
12
|
+
from tbr_deal_finder.retailer import Chirp, RETAILER_MAP, LibroFM, Kindle
|
8
13
|
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
14
|
from tbr_deal_finder.retailer.models import Retailer
|
16
|
-
from tbr_deal_finder.utils import
|
15
|
+
from tbr_deal_finder.utils import execute_query, get_duckdb_conn
|
17
16
|
|
18
17
|
|
19
18
|
def _library_export_tbr_books(config: Config, tbr_book_map: dict[str: Book]):
|
@@ -35,7 +34,7 @@ def _library_export_tbr_books(config: Config, tbr_book_map: dict[str: Book]):
|
|
35
34
|
authors = get_book_authors(book_dict)
|
36
35
|
|
37
36
|
key = get_title_id(title, authors, BookFormat.NA)
|
38
|
-
if key in tbr_book_map
|
37
|
+
if key in tbr_book_map:
|
39
38
|
continue
|
40
39
|
|
41
40
|
tbr_book_map[key] = Book(
|
@@ -46,33 +45,9 @@ def _library_export_tbr_books(config: Config, tbr_book_map: dict[str: Book]):
|
|
46
45
|
current_price=0,
|
47
46
|
timepoint=config.run_time,
|
48
47
|
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
48
|
)
|
52
49
|
|
53
50
|
|
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
51
|
async def _retailer_wishlist(config: Config, tbr_book_map: dict[str: Book]):
|
77
52
|
"""Adds wishlist books in the library export to the provided tbr_book_map
|
78
53
|
Books added here has the format the retailer sells (e.g. Audiobook)
|
@@ -102,19 +77,263 @@ async def _retailer_wishlist(config: Config, tbr_book_map: dict[str: Book]):
|
|
102
77
|
|
103
78
|
tbr_book_map[key] = book
|
104
79
|
|
105
|
-
if requires_audiobook_list_price_default(config):
|
106
|
-
await _apply_dynamic_audiobook_list_price(config, tbr_book_map)
|
107
80
|
|
81
|
+
async def _get_raw_tbr_books(config: Config) -> list[Book]:
|
82
|
+
"""Gets books in any library export or tracked retailer wishlist
|
108
83
|
|
109
|
-
|
110
|
-
|
84
|
+
Excludes books in the format they are owned.
|
85
|
+
Example: User owns Dungeon Crawler Carl Audiobook but it's on the user TBR.
|
86
|
+
Result - Deals will be tracked for the Dungeon Crawler Carl EBook (if tracking kindle deals)
|
111
87
|
|
88
|
+
:param config:
|
89
|
+
:return:
|
90
|
+
"""
|
91
|
+
|
92
|
+
owned_books = await get_owned_books(config)
|
93
|
+
tracking_audiobooks = config.is_tracking_format(book_format=BookFormat.AUDIOBOOK)
|
94
|
+
tracking_ebooks = config.is_tracking_format(book_format=BookFormat.EBOOK)
|
95
|
+
|
96
|
+
tbr_book_map: dict[str: Book] = {}
|
112
97
|
# Get TBRs specified in the user library (StoryGraph/GoodReads) export
|
113
98
|
_library_export_tbr_books(config, tbr_book_map)
|
114
|
-
|
115
99
|
# Pull wishlist from tracked retailers
|
116
100
|
await _retailer_wishlist(config, tbr_book_map)
|
101
|
+
raw_tbr_books = list(tbr_book_map.values())
|
102
|
+
|
103
|
+
response: list[Book] = []
|
104
|
+
|
105
|
+
owned_book_title_map: dict[str, set] = defaultdict(set)
|
106
|
+
for book in owned_books:
|
107
|
+
owned_book_title_map[book.full_title_str].add(book.format)
|
108
|
+
|
109
|
+
for book in raw_tbr_books:
|
110
|
+
owned_formats = owned_book_title_map.get(book.full_title_str)
|
111
|
+
if not owned_formats:
|
112
|
+
response.append(book)
|
113
|
+
elif BookFormat.NA in owned_formats:
|
114
|
+
continue
|
115
|
+
elif tracking_audiobooks and BookFormat.AUDIOBOOK not in owned_formats:
|
116
|
+
book.format = BookFormat.AUDIOBOOK
|
117
|
+
response.append(book)
|
118
|
+
elif tracking_ebooks and BookFormat.EBOOK not in owned_formats:
|
119
|
+
book.format = BookFormat.EBOOK
|
120
|
+
response.append(book)
|
121
|
+
|
122
|
+
return response
|
123
|
+
|
124
|
+
|
125
|
+
async def _set_tbr_book_attr(
|
126
|
+
tbr_books: list[Book],
|
127
|
+
target_attr: str,
|
128
|
+
get_book_callable: Callable[[Book, asyncio.Semaphore], Awaitable[Book]],
|
129
|
+
tbr_book_attr: Optional[str] = None
|
130
|
+
):
|
131
|
+
if not tbr_books:
|
132
|
+
return
|
133
|
+
|
134
|
+
if not tbr_book_attr:
|
135
|
+
tbr_book_attr = target_attr
|
136
|
+
|
137
|
+
tbr_books_map = {b.full_title_str: b for b in tbr_books}
|
138
|
+
tbr_books_copy = copy.deepcopy(tbr_books)
|
139
|
+
semaphore = asyncio.Semaphore(5)
|
140
|
+
human_readable_name = target_attr.replace("_", " ").title()
|
141
|
+
|
142
|
+
# Get books with the appropriate transform applied
|
143
|
+
# Responsibility is on the callable here
|
144
|
+
enriched_books = await tqdm_asyncio.gather(
|
145
|
+
*[
|
146
|
+
get_book_callable(book, semaphore) for book in tbr_books_copy
|
147
|
+
],
|
148
|
+
desc=f"Getting required {human_readable_name} info"
|
149
|
+
)
|
150
|
+
for enriched_book in enriched_books:
|
151
|
+
book = tbr_books_map[enriched_book.full_title_str]
|
152
|
+
setattr(
|
153
|
+
book,
|
154
|
+
tbr_book_attr,
|
155
|
+
getattr(enriched_book, target_attr)
|
156
|
+
)
|
157
|
+
|
158
|
+
|
159
|
+
def _requires_audiobook_list_price(config: Config):
|
160
|
+
return bool(
|
161
|
+
"Libro.FM" in config.tracked_retailers
|
162
|
+
and "Audible" not in config.tracked_retailers
|
163
|
+
and "Chirp" not in config.tracked_retailers
|
164
|
+
)
|
165
|
+
|
166
|
+
|
167
|
+
async def _maybe_set_audiobook_list_price(config: Config, new_tbr_books: list[Book]):
|
168
|
+
"""Set a default list price for audiobooks
|
169
|
+
|
170
|
+
Only set if not currently set and the only audiobook retailer is Libro.FM
|
171
|
+
Libro.FM doesn't include the actual default price in its response, so this grabs the price reported by Chirp.
|
172
|
+
Chirp doesn't require a login to get this price info making it ideal in this instance.
|
173
|
+
|
174
|
+
:param config:
|
175
|
+
:return:
|
176
|
+
"""
|
177
|
+
if not _requires_audiobook_list_price(config):
|
178
|
+
return
|
179
|
+
|
180
|
+
chirp = Chirp()
|
181
|
+
relevant_tbr_books = [
|
182
|
+
book
|
183
|
+
for book in new_tbr_books
|
184
|
+
if book.format in [BookFormat.AUDIOBOOK, BookFormat.NA]
|
185
|
+
]
|
186
|
+
|
187
|
+
await _set_tbr_book_attr(
|
188
|
+
relevant_tbr_books,
|
189
|
+
"list_price",
|
190
|
+
chirp.get_book,
|
191
|
+
"audiobook_list_price"
|
192
|
+
)
|
117
193
|
|
118
|
-
return list(tbr_book_map.values())
|
119
194
|
|
195
|
+
async def _maybe_set_audiobook_isbn(config: Config, new_tbr_books: list[Book]):
|
196
|
+
"""To get the price from Libro.fm for a book, you need its ISBN
|
197
|
+
"""
|
198
|
+
if "Libro.FM" not in config.tracked_retailers:
|
199
|
+
return
|
200
|
+
|
201
|
+
libro_fm = LibroFM()
|
202
|
+
await libro_fm.set_auth()
|
203
|
+
|
204
|
+
relevant_tbr_books = [
|
205
|
+
book
|
206
|
+
for book in new_tbr_books
|
207
|
+
if book.format in [BookFormat.AUDIOBOOK, BookFormat.NA]
|
208
|
+
]
|
209
|
+
|
210
|
+
await _set_tbr_book_attr(
|
211
|
+
relevant_tbr_books,
|
212
|
+
"audiobook_isbn",
|
213
|
+
libro_fm.get_book_isbn,
|
214
|
+
)
|
215
|
+
|
216
|
+
|
217
|
+
async def _maybe_set_ebook_asin(config: Config, new_tbr_books: list[Book]):
|
218
|
+
"""To get the price from kindle for a book, you need its asin
|
219
|
+
"""
|
220
|
+
if "Kindle" not in config.tracked_retailers:
|
221
|
+
return
|
222
|
+
|
223
|
+
kindle = Kindle()
|
224
|
+
await kindle.set_auth()
|
225
|
+
|
226
|
+
relevant_tbr_books = [
|
227
|
+
book
|
228
|
+
for book in new_tbr_books
|
229
|
+
if book.format in [BookFormat.EBOOK, BookFormat.NA]
|
230
|
+
]
|
231
|
+
|
232
|
+
await _set_tbr_book_attr(
|
233
|
+
relevant_tbr_books,
|
234
|
+
"ebook_asin",
|
235
|
+
kindle.get_book_asin,
|
236
|
+
)
|
237
|
+
|
238
|
+
|
239
|
+
def get_book_authors(book: dict) -> str:
|
240
|
+
if authors := book.get('Authors'):
|
241
|
+
return authors
|
242
|
+
|
243
|
+
authors = book['Author']
|
244
|
+
if additional_authors := book.get("Additional Authors"):
|
245
|
+
authors = f"{authors}, {additional_authors}"
|
246
|
+
|
247
|
+
return authors
|
248
|
+
|
249
|
+
|
250
|
+
def get_book_title(book: dict) -> str:
|
251
|
+
title = book['Title']
|
252
|
+
return title.split("(")[0].strip()
|
253
|
+
|
254
|
+
|
255
|
+
def is_tbr_book(book: dict) -> bool:
|
256
|
+
if "Read Status" in book:
|
257
|
+
return book["Read Status"] == "to-read"
|
258
|
+
elif "Bookshelves" in book:
|
259
|
+
return "to-read" in book["Bookshelves"]
|
260
|
+
else:
|
261
|
+
return True
|
262
|
+
|
263
|
+
|
264
|
+
def reprocess_incomplete_tbr_books(config: Config):
|
265
|
+
db_conn = get_duckdb_conn()
|
266
|
+
|
267
|
+
if config.is_tracking_format(BookFormat.EBOOK):
|
268
|
+
# Replace any tbr_books missing required attr
|
269
|
+
db_conn.execute(
|
270
|
+
"DELETE FROM tbr_book WHERE ebook_asin IS NULL AND format != $book_format",
|
271
|
+
parameters=dict(book_format=BookFormat.AUDIOBOOK.value)
|
272
|
+
)
|
273
|
+
|
274
|
+
if LibroFM().name in config.tracked_retailers:
|
275
|
+
# Replace any tbr_books missing required attr
|
276
|
+
db_conn.execute(
|
277
|
+
"DELETE FROM tbr_book WHERE audiobook_isbn IS NULL AND format != $book_format",
|
278
|
+
parameters=dict(book_format=BookFormat.EBOOK.value)
|
279
|
+
)
|
280
|
+
|
281
|
+
if _requires_audiobook_list_price(config):
|
282
|
+
# Replace any tbr_books missing required attr
|
283
|
+
db_conn.execute(
|
284
|
+
"DELETE FROM tbr_book WHERE audiobook_list_price IS NULL AND format != $book_format",
|
285
|
+
parameters=dict(book_format=BookFormat.EBOOK.value)
|
286
|
+
)
|
287
|
+
|
288
|
+
|
289
|
+
async def sync_tbr_books(config: Config):
|
290
|
+
raw_tbr_books = await _get_raw_tbr_books(config)
|
291
|
+
db_conn = get_duckdb_conn()
|
292
|
+
|
293
|
+
if not raw_tbr_books:
|
294
|
+
return
|
295
|
+
|
296
|
+
df = pd.DataFrame([book.tbr_dict() for book in raw_tbr_books])
|
297
|
+
db_conn.register("_df", df)
|
298
|
+
db_conn.execute("CREATE OR REPLACE TABLE _latest_tbr_book AS SELECT * FROM _df;")
|
299
|
+
db_conn.unregister("_df")
|
300
|
+
|
301
|
+
# Remove books no longer on user tbr
|
302
|
+
db_conn.execute(
|
303
|
+
"DELETE FROM tbr_book WHERE book_id NOT IN (SELECT book_id FROM _latest_tbr_book)"
|
304
|
+
)
|
305
|
+
|
306
|
+
# Remove books from _latest_tbr_book for further processing for books already in tbr_book
|
307
|
+
db_conn.execute(
|
308
|
+
"DELETE FROM _latest_tbr_book WHERE book_id IN (SELECT book_id FROM tbr_book)"
|
309
|
+
)
|
310
|
+
|
311
|
+
new_tbr_book_data = execute_query(
|
312
|
+
db_conn,
|
313
|
+
"SELECT * EXCLUDE(book_id) FROM _latest_tbr_book"
|
314
|
+
)
|
315
|
+
|
316
|
+
new_tbr_books = [Book(retailer="N/A", timepoint=config.run_time, **b) for b in new_tbr_book_data]
|
317
|
+
if not new_tbr_books:
|
318
|
+
return
|
319
|
+
|
320
|
+
await _maybe_set_audiobook_list_price(config, new_tbr_books)
|
321
|
+
await _maybe_set_audiobook_isbn(config, new_tbr_books)
|
322
|
+
await _maybe_set_ebook_asin(config, new_tbr_books)
|
323
|
+
|
324
|
+
df = pd.DataFrame([book.tbr_dict() for book in new_tbr_books])
|
325
|
+
db_conn.register("_df", df)
|
326
|
+
db_conn.execute("INSERT INTO tbr_book SELECT * FROM _df;")
|
327
|
+
db_conn.unregister("_df")
|
328
|
+
|
329
|
+
|
330
|
+
async def get_tbr_books(config: Config) -> list[Book]:
|
331
|
+
await sync_tbr_books(config)
|
332
|
+
|
333
|
+
db_conn = get_duckdb_conn()
|
334
|
+
tbr_book_data = execute_query(
|
335
|
+
db_conn,
|
336
|
+
"SELECT * EXCLUDE(book_id) FROM tbr_book"
|
337
|
+
)
|
120
338
|
|
339
|
+
return [Book(retailer="N/A", timepoint=config.run_time, **b) for b in tbr_book_data]
|
@@ -1,6 +1,6 @@
|
|
1
1
|
Metadata-Version: 2.4
|
2
2
|
Name: tbr-deal-finder
|
3
|
-
Version: 0.
|
3
|
+
Version: 0.2.0
|
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
|
@@ -37,6 +37,9 @@ Track price drops and find deals on books in your TBR (To Be Read) and wishlist
|
|
37
37
|
* Chirp
|
38
38
|
* Libro.fm
|
39
39
|
|
40
|
+
### EBooks
|
41
|
+
* Kindle
|
42
|
+
|
40
43
|
### Locales
|
41
44
|
* US
|
42
45
|
* CA
|
@@ -56,7 +59,7 @@ Track price drops and find deals on books in your TBR (To Be Read) and wishlist
|
|
56
59
|
1. If it's not already on your computer, download Python https://www.python.org/downloads/
|
57
60
|
1. tbr-deal-finder requires Python3.13 or higher
|
58
61
|
2. Optional: Install and use virtualenv
|
59
|
-
3. Open your Terminal/
|
62
|
+
3. Open your Terminal/Command Prompt
|
60
63
|
4. Run `pip3.13 install tbr-deal-finder`
|
61
64
|
|
62
65
|
### UV
|
@@ -0,0 +1,24 @@
|
|
1
|
+
tbr_deal_finder/__init__.py,sha256=WCoj0GZrRiCQlrpkLTw1VUeJmX-RtBLdLqnFYn1Es_4,208
|
2
|
+
tbr_deal_finder/book.py,sha256=vCvkjU98mI0Z7WW_Z3GppnI4aem9ht-flB8HB4RCujQ,6107
|
3
|
+
tbr_deal_finder/cli.py,sha256=C4F2rbPrfYNqlmolx08ZHDCcFJuiPbkc4ECXUO25kmI,7446
|
4
|
+
tbr_deal_finder/config.py,sha256=-TtZLv4kVBf56xPkgAdKXeVRV0qw8MZ53XHBQ1HnVX8,3978
|
5
|
+
tbr_deal_finder/migrations.py,sha256=_ZxUXzGyEFYlPlpzMvViDVPZJc5BNOiixj150U8HRFc,4224
|
6
|
+
tbr_deal_finder/owned_books.py,sha256=Cf1VeiSg7XBi_TXptJfy5sO1mEgMMQWbJ_P6SzAx0nQ,516
|
7
|
+
tbr_deal_finder/retailer_deal.py,sha256=jv32WSOtxVxCkxTCLkOqSkcHGHhWfbD4nSxY42Cqk38,6422
|
8
|
+
tbr_deal_finder/tracked_books.py,sha256=TdVD5kIgdkDoQNwBIsOikrvFiQiIezmSYf64F5cBN0o,10856
|
9
|
+
tbr_deal_finder/utils.py,sha256=_4wdGFDtqCdMyoMnwTDiHgCR4WQLAcQr8LlZZZUcq6E,1357
|
10
|
+
tbr_deal_finder/queries/get_active_deals.sql,sha256=nh0F1lRV6YVrUV7gsQpjsgfXmN9R0peBeMHRifjgpUM,212
|
11
|
+
tbr_deal_finder/queries/get_deals_found_at.sql,sha256=KqrtQk7FS4Hf74RyL1r-oD2D-RJz1urrxKxkwlvjAro,139
|
12
|
+
tbr_deal_finder/queries/latest_deal_last_ran_most_recent_success.sql,sha256=W4cNMAHtcW2DzQyPL8SHHFcbVZQKVK2VfTzazxC3LJU,107
|
13
|
+
tbr_deal_finder/retailer/__init__.py,sha256=OD6jUYV8LaURxqHnZq-aiFi7OdWG6qWznRlF_g246lo,316
|
14
|
+
tbr_deal_finder/retailer/amazon.py,sha256=rYJlBT4JsOg7QVIhJ8a7xJKUjczP_RMK2m-ddH7FjlQ,2472
|
15
|
+
tbr_deal_finder/retailer/audible.py,sha256=qwXDKc1W8vGGhqvU2YI7hNfD1rHz2yL-8foXstxb8t8,3991
|
16
|
+
tbr_deal_finder/retailer/chirp.py,sha256=f_O-6X9duR_gBT8UWDxDr-KQYSRFOOiYOX9Az2pCi6Y,9183
|
17
|
+
tbr_deal_finder/retailer/kindle.py,sha256=ELdKSzKMCmZWw8TjGHihuyZcgqwVygi94mZFOYQ61qY,4489
|
18
|
+
tbr_deal_finder/retailer/librofm.py,sha256=Oi9UlkyCqdI-PSRApGSCv_TWP7yWwvYEADOZleAOSmM,6357
|
19
|
+
tbr_deal_finder/retailer/models.py,sha256=xm99ngt_Ze7yyEwttddkpwL7xjy0YfcFAduV6Rsx63M,2510
|
20
|
+
tbr_deal_finder-0.2.0.dist-info/METADATA,sha256=ZS-Q4WHHC3Y7BGbSC7j6ClKizcdVMSVfHJ2KpbV7DXk,4363
|
21
|
+
tbr_deal_finder-0.2.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
22
|
+
tbr_deal_finder-0.2.0.dist-info/entry_points.txt,sha256=y_KG1k8xVCY8gngSZ-na2bkK-tTLUdOc_qZ9Djwldv0,60
|
23
|
+
tbr_deal_finder-0.2.0.dist-info/licenses/LICENSE,sha256=rNc0wNPn4d4HHu6ZheJzeUaz_FbJ4rj2Dr2FjAivkNg,1064
|
24
|
+
tbr_deal_finder-0.2.0.dist-info/RECORD,,
|
@@ -1,208 +0,0 @@
|
|
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_full_title_str
|
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 config.library_export_paths:
|
65
|
-
return
|
66
|
-
|
67
|
-
if not column_name:
|
68
|
-
column_name = attr_name
|
69
|
-
|
70
|
-
books_requiring_check_map = dict()
|
71
|
-
book_to_col_val_map = dict()
|
72
|
-
|
73
|
-
# Iterate all library export paths
|
74
|
-
for library_export_path in config.library_export_paths:
|
75
|
-
with open(library_export_path, 'r', newline='', encoding='utf-8') as file:
|
76
|
-
# Use csv.DictReader to get dictionaries with column headers
|
77
|
-
for book_dict in csv.DictReader(file):
|
78
|
-
if not is_tbr_book(book_dict):
|
79
|
-
continue
|
80
|
-
|
81
|
-
title = get_book_title(book_dict)
|
82
|
-
authors = get_book_authors(book_dict)
|
83
|
-
key = get_full_title_str(title, authors)
|
84
|
-
|
85
|
-
if column_name in book_dict:
|
86
|
-
# Keep state of value for this book/key
|
87
|
-
# in the event another export has the same book but the value is not set
|
88
|
-
book_to_col_val_map[key] = book_dict[column_name]
|
89
|
-
# Value has been found so a check no longer needs to be performed
|
90
|
-
books_requiring_check_map.pop(key, None)
|
91
|
-
elif key not in book_to_col_val_map:
|
92
|
-
# Not found, add the book to those requiring the column val to be set
|
93
|
-
books_requiring_check_map[key] = Book(
|
94
|
-
retailer="N/A",
|
95
|
-
title=title,
|
96
|
-
authors=authors,
|
97
|
-
list_price=0,
|
98
|
-
current_price=0,
|
99
|
-
timepoint=config.run_time,
|
100
|
-
format=BookFormat.NA
|
101
|
-
)
|
102
|
-
|
103
|
-
if not books_requiring_check_map:
|
104
|
-
# Everything was resolved, nothing else to do
|
105
|
-
return
|
106
|
-
|
107
|
-
semaphore = asyncio.Semaphore(5)
|
108
|
-
human_readable_name = attr_name.replace("_", " ").title()
|
109
|
-
|
110
|
-
# Get books with the appropriate transform applied
|
111
|
-
# Responsibility is on the callable here
|
112
|
-
enriched_books = await tqdm_asyncio.gather(
|
113
|
-
*[
|
114
|
-
get_book_callable(book, config.run_time, semaphore) for book in books_requiring_check_map.values()
|
115
|
-
],
|
116
|
-
desc=f"Getting required {human_readable_name} info"
|
117
|
-
)
|
118
|
-
updated_book_map = {
|
119
|
-
b.full_title_str: b
|
120
|
-
for b in enriched_books
|
121
|
-
}
|
122
|
-
|
123
|
-
|
124
|
-
# Go back and now add the new column where it hasn't been set
|
125
|
-
for library_export_path in config.library_export_paths:
|
126
|
-
with open(library_export_path, 'r', newline='', encoding='utf-8') as file:
|
127
|
-
reader = csv.DictReader(file)
|
128
|
-
field_names = list(reader.fieldnames) + [column_name]
|
129
|
-
file_content = [book_dict for book_dict in reader]
|
130
|
-
if not file_content or column_name in file_content[0]:
|
131
|
-
continue
|
132
|
-
|
133
|
-
with tempfile.NamedTemporaryFile(mode='w', delete=False, newline='') as temp_file:
|
134
|
-
temp_filename = temp_file.name
|
135
|
-
writer = csv.DictWriter(temp_file, fieldnames=field_names)
|
136
|
-
writer.writeheader()
|
137
|
-
|
138
|
-
for book_dict in file_content:
|
139
|
-
if is_tbr_book(book_dict):
|
140
|
-
title = get_book_title(book_dict)
|
141
|
-
authors = get_book_authors(book_dict)
|
142
|
-
key = get_full_title_str(title, authors)
|
143
|
-
|
144
|
-
if key in book_to_col_val_map:
|
145
|
-
col_val = book_to_col_val_map[key]
|
146
|
-
elif key in updated_book_map:
|
147
|
-
book = updated_book_map[key]
|
148
|
-
col_val = getattr(book, attr_name)
|
149
|
-
else:
|
150
|
-
col_val = ""
|
151
|
-
|
152
|
-
book_dict[column_name] = col_val
|
153
|
-
else:
|
154
|
-
book_dict[column_name] = ""
|
155
|
-
|
156
|
-
writer.writerow(book_dict)
|
157
|
-
|
158
|
-
shutil.move(temp_filename, library_export_path)
|
159
|
-
|
160
|
-
|
161
|
-
async def _maybe_set_library_export_audiobook_isbn(config: Config):
|
162
|
-
"""To get the price from Libro.fm for a book, you need its ISBN
|
163
|
-
|
164
|
-
As opposed to trying to get that every time latest-deals is run
|
165
|
-
we're just updating the export csv once to include the ISBN.
|
166
|
-
|
167
|
-
Unfortunately, we do have to get it at run time for wishlists.
|
168
|
-
"""
|
169
|
-
if "Libro.FM" not in config.tracked_retailers:
|
170
|
-
return
|
171
|
-
|
172
|
-
libro_fm = LibroFM()
|
173
|
-
await libro_fm.set_auth()
|
174
|
-
|
175
|
-
await _maybe_set_column_for_library_exports(
|
176
|
-
config,
|
177
|
-
"audiobook_isbn",
|
178
|
-
libro_fm.get_book_isbn,
|
179
|
-
)
|
180
|
-
|
181
|
-
|
182
|
-
async def _maybe_set_library_export_audiobook_list_price(config: Config):
|
183
|
-
"""Set a default list price for audiobooks
|
184
|
-
|
185
|
-
Only set if not currently set and the only audiobook retailer is Libro.FM
|
186
|
-
Libro.FM doesn't include the actual default price in its response, so this grabs the price reported by Chirp.
|
187
|
-
Chirp doesn't require a login to get this price info making it ideal in this instance.
|
188
|
-
|
189
|
-
:param config:
|
190
|
-
:return:
|
191
|
-
"""
|
192
|
-
if not requires_audiobook_list_price_default(config):
|
193
|
-
return
|
194
|
-
|
195
|
-
chirp = Chirp()
|
196
|
-
await chirp.set_auth()
|
197
|
-
|
198
|
-
await _maybe_set_column_for_library_exports(
|
199
|
-
config,
|
200
|
-
"list_price",
|
201
|
-
chirp.get_book,
|
202
|
-
"audiobook_list_price"
|
203
|
-
)
|
204
|
-
|
205
|
-
|
206
|
-
async def maybe_enrich_library_exports(config: Config):
|
207
|
-
await _maybe_set_library_export_audiobook_isbn(config)
|
208
|
-
await _maybe_set_library_export_audiobook_list_price(config)
|
@@ -1,23 +0,0 @@
|
|
1
|
-
tbr_deal_finder/__init__.py,sha256=WCoj0GZrRiCQlrpkLTw1VUeJmX-RtBLdLqnFYn1Es_4,208
|
2
|
-
tbr_deal_finder/book.py,sha256=JUhhDAV_vajhSyaD5begFvX_HPwEiZfojQ2x57qrf5M,4563
|
3
|
-
tbr_deal_finder/cli.py,sha256=iwmbUxwqD6HtKppf2QlDMENFnYxTnPQWSK1WLUpyOW8,7357
|
4
|
-
tbr_deal_finder/config.py,sha256=I69JruWIlnwxNiUMyOFq3K5sMmtXJKQxLKBU98DM008,3662
|
5
|
-
tbr_deal_finder/library_exports.py,sha256=hs2_GE0HP78EQ8GL0Lmalq-ihSp88i1OL-3VLD0Djhk,7163
|
6
|
-
tbr_deal_finder/migrations.py,sha256=6_WV55bm71UCFrcFrfJXlEX5uDrgnNTWZPq6vZTg18o,3733
|
7
|
-
tbr_deal_finder/owned_books.py,sha256=Cf1VeiSg7XBi_TXptJfy5sO1mEgMMQWbJ_P6SzAx0nQ,516
|
8
|
-
tbr_deal_finder/retailer_deal.py,sha256=UGVb8wxv98vEWy9wX6UM5ePhIa00xHPtzJCgngximHc,6949
|
9
|
-
tbr_deal_finder/tracked_books.py,sha256=EKecARSOMPPkmjSSWfWQw9B-TnoSd3-6AQepc2EYTz0,4031
|
10
|
-
tbr_deal_finder/utils.py,sha256=_4wdGFDtqCdMyoMnwTDiHgCR4WQLAcQr8LlZZZUcq6E,1357
|
11
|
-
tbr_deal_finder/queries/get_active_deals.sql,sha256=jILZK5UVNPLbbKWgqMW0brEZyCb9XBdQZJLHRULoQC4,195
|
12
|
-
tbr_deal_finder/queries/get_deals_found_at.sql,sha256=1vAE8PsAvfFi0SbvoUw8pvLwRN9VGYTJ7AVI3rmxXEI,122
|
13
|
-
tbr_deal_finder/queries/latest_deal_last_ran_most_recent_success.sql,sha256=W4cNMAHtcW2DzQyPL8SHHFcbVZQKVK2VfTzazxC3LJU,107
|
14
|
-
tbr_deal_finder/retailer/__init__.py,sha256=WePMSN7vi4EL_uPiAH6ogNNE-kRQe4OHT4CYGTKvBSk,243
|
15
|
-
tbr_deal_finder/retailer/audible.py,sha256=kgDNobu7uYV5IwReTDL_e0J731fMdOiJBipsno7Zv0A,6561
|
16
|
-
tbr_deal_finder/retailer/chirp.py,sha256=8IaVVbUcY-bLV6cy4wKXhKmW2qJy6HksxxG1uLpqSeA,10063
|
17
|
-
tbr_deal_finder/retailer/librofm.py,sha256=wN51UrDVaHb4XwoO0MY1_7ivhAE_BxoBWDavd7MpRxE,7413
|
18
|
-
tbr_deal_finder/retailer/models.py,sha256=vomL99LDP_52r61W1CBE34yxAWteD_QE5NeqSATgnAE,1512
|
19
|
-
tbr_deal_finder-0.1.7.dist-info/METADATA,sha256=nYxkGPCh7SOIg6RRktVidC7YpPfoxaWT6jZJ0JmpR_E,4343
|
20
|
-
tbr_deal_finder-0.1.7.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
21
|
-
tbr_deal_finder-0.1.7.dist-info/entry_points.txt,sha256=y_KG1k8xVCY8gngSZ-na2bkK-tTLUdOc_qZ9Djwldv0,60
|
22
|
-
tbr_deal_finder-0.1.7.dist-info/licenses/LICENSE,sha256=rNc0wNPn4d4HHu6ZheJzeUaz_FbJ4rj2Dr2FjAivkNg,1064
|
23
|
-
tbr_deal_finder-0.1.7.dist-info/RECORD,,
|
File without changes
|
File without changes
|
File without changes
|