tbr-deal-finder 0.1.4__tar.gz → 0.1.6__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- tbr_deal_finder-0.1.6/CHANGELOG.md +21 -0
- {tbr_deal_finder-0.1.4 → tbr_deal_finder-0.1.6}/PKG-INFO +4 -3
- {tbr_deal_finder-0.1.4 → tbr_deal_finder-0.1.6}/README.md +3 -2
- {tbr_deal_finder-0.1.4 → tbr_deal_finder-0.1.6}/pyproject.toml +1 -1
- {tbr_deal_finder-0.1.4 → tbr_deal_finder-0.1.6}/tbr_deal_finder/book.py +18 -5
- {tbr_deal_finder-0.1.4 → tbr_deal_finder-0.1.6}/tbr_deal_finder/cli.py +43 -19
- tbr_deal_finder-0.1.6/tbr_deal_finder/library_exports.py +205 -0
- {tbr_deal_finder-0.1.4 → tbr_deal_finder-0.1.6}/tbr_deal_finder/retailer/audible.py +53 -26
- tbr_deal_finder-0.1.6/tbr_deal_finder/retailer/chirp.py +182 -0
- {tbr_deal_finder-0.1.4 → tbr_deal_finder-0.1.6}/tbr_deal_finder/retailer/librofm.py +75 -20
- {tbr_deal_finder-0.1.4 → tbr_deal_finder-0.1.6}/tbr_deal_finder/retailer/models.py +18 -2
- {tbr_deal_finder-0.1.4 → tbr_deal_finder-0.1.6}/tbr_deal_finder/retailer_deal.py +19 -10
- tbr_deal_finder-0.1.6/tbr_deal_finder/tracked_books.py +120 -0
- {tbr_deal_finder-0.1.4 → tbr_deal_finder-0.1.6}/tbr_deal_finder/utils.py +17 -0
- {tbr_deal_finder-0.1.4 → tbr_deal_finder-0.1.6}/uv.lock +1 -1
- tbr_deal_finder-0.1.4/tbr_deal_finder/library_exports.py +0 -155
- tbr_deal_finder-0.1.4/tbr_deal_finder/retailer/chirp.py +0 -79
- {tbr_deal_finder-0.1.4 → tbr_deal_finder-0.1.6}/.github/workflows/publish-to-pypi.yaml +0 -0
- {tbr_deal_finder-0.1.4 → tbr_deal_finder-0.1.6}/.gitignore +0 -0
- {tbr_deal_finder-0.1.4 → tbr_deal_finder-0.1.6}/.python-version +0 -0
- {tbr_deal_finder-0.1.4 → tbr_deal_finder-0.1.6}/LICENSE +0 -0
- {tbr_deal_finder-0.1.4 → tbr_deal_finder-0.1.6}/tbr_deal_finder/__init__.py +0 -0
- {tbr_deal_finder-0.1.4 → tbr_deal_finder-0.1.6}/tbr_deal_finder/config.py +0 -0
- {tbr_deal_finder-0.1.4 → tbr_deal_finder-0.1.6}/tbr_deal_finder/migrations.py +0 -0
- {tbr_deal_finder-0.1.4 → tbr_deal_finder-0.1.6}/tbr_deal_finder/queries/get_active_deals.sql +0 -0
- {tbr_deal_finder-0.1.4 → tbr_deal_finder-0.1.6}/tbr_deal_finder/queries/get_deals_found_at.sql +0 -0
- {tbr_deal_finder-0.1.4 → tbr_deal_finder-0.1.6}/tbr_deal_finder/queries/latest_deal_last_ran_most_recent_success.sql +0 -0
- {tbr_deal_finder-0.1.4 → tbr_deal_finder-0.1.6}/tbr_deal_finder/retailer/__init__.py +0 -0
@@ -0,0 +1,21 @@
|
|
1
|
+
|
2
|
+
# Change Log
|
3
|
+
|
4
|
+
## 0.1.6 (July 30, 2025)
|
5
|
+
|
6
|
+
Notes:
|
7
|
+
* tbr-deal-finder now also tracks deals on the books in your wishlist. Works for all retailers.
|
8
|
+
|
9
|
+
BUG FIXES:
|
10
|
+
* Fixed issue where no deals would display if libro is the only tracked audiobook retailer.
|
11
|
+
* Fixed retailer cli setup forcing a user to select at least two audiobook retailers.
|
12
|
+
|
13
|
+
## 0.1.5 (July 30, 2025)
|
14
|
+
|
15
|
+
Notes:
|
16
|
+
* Added formatting to select messages to make the messages purpose clearer.
|
17
|
+
|
18
|
+
BUG FIXES:
|
19
|
+
* Fixed issue getting books from libro and chirp too aggressively
|
20
|
+
* User must now track deals for at least one retailer
|
21
|
+
|
@@ -1,6 +1,6 @@
|
|
1
1
|
Metadata-Version: 2.4
|
2
2
|
Name: tbr-deal-finder
|
3
|
-
Version: 0.1.
|
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
|
36
|
+
* Libro.fm
|
36
37
|
|
37
38
|
### Locales
|
38
39
|
* US
|
@@ -4,7 +4,8 @@ Track price drops and find deals on books in your TBR (To Be Read) list across a
|
|
4
4
|
|
5
5
|
## Features
|
6
6
|
- Uses your StoryGraph exports, Goodreads exports, and custom csvs (spreadsheet) to track book deals
|
7
|
-
- Supports multiple of the library exports above
|
7
|
+
- Supports multiple of the library exports above
|
8
|
+
- Tracks deals on the wishlist of all your configured retailers like audible
|
8
9
|
- Supports multiple locales and currencies
|
9
10
|
- Finds the latest and active deals from supported sellers
|
10
11
|
- Simple CLI interface for setup and usage
|
@@ -15,7 +16,7 @@ Track price drops and find deals on books in your TBR (To Be Read) list across a
|
|
15
16
|
### Audiobooks
|
16
17
|
* Audible
|
17
18
|
* Chirp
|
18
|
-
* Libro.fm
|
19
|
+
* Libro.fm
|
19
20
|
|
20
21
|
### Locales
|
21
22
|
* US
|
@@ -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.
|
47
|
+
self.deal_id = f"{self.title}__{self.normalized_authors}__{self.format}__{self.retailer}"
|
40
48
|
|
41
49
|
if isinstance(self.format, str):
|
42
50
|
self.format = BookFormat(self.format)
|
43
51
|
|
44
|
-
self.current_price = round(self.current_price, 2)
|
45
|
-
self.list_price = round(self.list_price, 2)
|
46
|
-
self.normalized_authors = get_normalized_authors(self.authors)
|
47
|
-
|
48
52
|
def discount(self) -> int:
|
49
53
|
return int((self.list_price/self.current_price - 1) * 100)
|
50
54
|
|
@@ -56,6 +60,10 @@ class Book:
|
|
56
60
|
def title_id(self) -> str:
|
57
61
|
return f"{self.title}__{self.normalized_authors}__{self.format}"
|
58
62
|
|
63
|
+
@property
|
64
|
+
def full_title_str(self) -> str:
|
65
|
+
return f"{self.title}__{self.normalized_authors}"
|
66
|
+
|
59
67
|
def list_price_string(self):
|
60
68
|
return self.price_to_string(self.list_price)
|
61
69
|
|
@@ -74,6 +82,7 @@ class Book:
|
|
74
82
|
response = dataclasses.asdict(self)
|
75
83
|
response["format"] = self.format.value
|
76
84
|
del response["audiobook_isbn"]
|
85
|
+
del response["audiobook_list_price"]
|
77
86
|
del response["exists"]
|
78
87
|
del response["normalized_authors"]
|
79
88
|
|
@@ -114,3 +123,7 @@ def get_normalized_authors(authors: Union[str, list[str]]) -> list[str]:
|
|
114
123
|
authors = [i for i in authors.split(",")]
|
115
124
|
|
116
125
|
return sorted([_AUTHOR_RE.sub('', unidecode(author)).lower() for author in authors])
|
126
|
+
|
127
|
+
|
128
|
+
def get_title_id(title: str, authors: Union[list, str], book_format: BookFormat) -> str:
|
129
|
+
return f"{title}__{get_normalized_authors(authors)}__{book_format.value}"
|
@@ -1,5 +1,6 @@
|
|
1
1
|
import asyncio
|
2
2
|
import os
|
3
|
+
import sys
|
3
4
|
from datetime import timedelta
|
4
5
|
from textwrap import dedent
|
5
6
|
from typing import Union
|
@@ -8,12 +9,19 @@ import click
|
|
8
9
|
import questionary
|
9
10
|
|
10
11
|
from tbr_deal_finder.config import Config
|
11
|
-
from tbr_deal_finder.library_exports import
|
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
|
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
|
-
|
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
|
-
|
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.
|
120
|
-
|
121
|
-
"
|
122
|
-
"
|
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
|
-
|
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
|
-
|
137
|
-
|
138
|
-
|
139
|
-
|
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
|
-
|
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(
|
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
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
243
|
+
echo_info("No deals found.")
|
220
244
|
|
221
245
|
|
222
246
|
if __name__ == '__main__':
|
@@ -0,0 +1,205 @@
|
|
1
|
+
import asyncio
|
2
|
+
import csv
|
3
|
+
import shutil
|
4
|
+
import tempfile
|
5
|
+
from datetime import datetime
|
6
|
+
from typing import Callable, Awaitable, Optional
|
7
|
+
|
8
|
+
from tqdm.asyncio import tqdm_asyncio
|
9
|
+
|
10
|
+
from tbr_deal_finder.book import Book, BookFormat, get_normalized_authors
|
11
|
+
from tbr_deal_finder.config import Config
|
12
|
+
from tbr_deal_finder.retailer import LibroFM, Chirp
|
13
|
+
|
14
|
+
|
15
|
+
def get_book_authors(book: dict) -> str:
|
16
|
+
if authors := book.get('Authors'):
|
17
|
+
return authors
|
18
|
+
|
19
|
+
authors = book['Author']
|
20
|
+
if additional_authors := book.get("Additional Authors"):
|
21
|
+
authors = f"{authors}, {additional_authors}"
|
22
|
+
|
23
|
+
return authors
|
24
|
+
|
25
|
+
|
26
|
+
def get_book_title(book: dict) -> str:
|
27
|
+
title = book['Title']
|
28
|
+
return title.split("(")[0].strip()
|
29
|
+
|
30
|
+
|
31
|
+
def is_tbr_book(book: dict) -> bool:
|
32
|
+
if "Read Status" in book:
|
33
|
+
return book["Read Status"] == "to-read"
|
34
|
+
elif "Bookshelves" in book:
|
35
|
+
return "to-read" in book["Bookshelves"]
|
36
|
+
else:
|
37
|
+
return True
|
38
|
+
|
39
|
+
|
40
|
+
def requires_audiobook_list_price_default(config: Config) -> bool:
|
41
|
+
return bool(
|
42
|
+
"Libro.FM" in config.tracked_retailers
|
43
|
+
and "Audible" not in config.tracked_retailers
|
44
|
+
and "Chirp" not in config.tracked_retailers
|
45
|
+
)
|
46
|
+
|
47
|
+
|
48
|
+
async def _maybe_set_column_for_library_exports(
|
49
|
+
config: Config,
|
50
|
+
attr_name: str,
|
51
|
+
get_book_callable: Callable[[Book, datetime, asyncio.Semaphore], Awaitable[Book]],
|
52
|
+
column_name: Optional[str] = None,
|
53
|
+
):
|
54
|
+
"""Adds a new column to all library exports that are missing it.
|
55
|
+
Uses get_book_callable to set the column value if a matching record couldn't be found
|
56
|
+
on that column in any other library export file.
|
57
|
+
|
58
|
+
:param config:
|
59
|
+
:param attr_name:
|
60
|
+
:param get_book_callable:
|
61
|
+
:param column_name:
|
62
|
+
:return:
|
63
|
+
"""
|
64
|
+
if not column_name:
|
65
|
+
column_name = attr_name
|
66
|
+
|
67
|
+
books_requiring_check_map = dict()
|
68
|
+
book_to_col_val_map = dict()
|
69
|
+
|
70
|
+
# Iterate all library export paths
|
71
|
+
for library_export_path in config.library_export_paths:
|
72
|
+
with open(library_export_path, 'r', newline='', encoding='utf-8') as file:
|
73
|
+
# Use csv.DictReader to get dictionaries with column headers
|
74
|
+
for book_dict in csv.DictReader(file):
|
75
|
+
if not is_tbr_book(book_dict):
|
76
|
+
continue
|
77
|
+
|
78
|
+
title = get_book_title(book_dict)
|
79
|
+
authors = get_book_authors(book_dict)
|
80
|
+
key = f'{title}__{get_normalized_authors(authors)}'
|
81
|
+
|
82
|
+
if column_name in book_dict:
|
83
|
+
# Keep state of value for this book/key
|
84
|
+
# in the event another export has the same book but the value is not set
|
85
|
+
book_to_col_val_map[key] = book_dict[column_name]
|
86
|
+
# Value has been found so a check no longer needs to be performed
|
87
|
+
books_requiring_check_map.pop(key, None)
|
88
|
+
elif key not in book_to_col_val_map:
|
89
|
+
# Not found, add the book to those requiring the column val to be set
|
90
|
+
books_requiring_check_map[key] = Book(
|
91
|
+
retailer="N/A",
|
92
|
+
title=title,
|
93
|
+
authors=authors,
|
94
|
+
list_price=0,
|
95
|
+
current_price=0,
|
96
|
+
timepoint=config.run_time,
|
97
|
+
format=BookFormat.NA
|
98
|
+
)
|
99
|
+
|
100
|
+
if not books_requiring_check_map:
|
101
|
+
# Everything was resolved, nothing else to do
|
102
|
+
return
|
103
|
+
|
104
|
+
semaphore = asyncio.Semaphore(5)
|
105
|
+
human_readable_name = attr_name.replace("_", " ").title()
|
106
|
+
|
107
|
+
# Get books with the appropriate transform applied
|
108
|
+
# Responsibility is on the callable here
|
109
|
+
enriched_books = await tqdm_asyncio.gather(
|
110
|
+
*[
|
111
|
+
get_book_callable(book, config.run_time, semaphore) for book in books_requiring_check_map.values()
|
112
|
+
],
|
113
|
+
desc=f"Getting required {human_readable_name} info"
|
114
|
+
)
|
115
|
+
updated_book_map = {
|
116
|
+
b.full_title_str: b
|
117
|
+
for b in enriched_books
|
118
|
+
}
|
119
|
+
|
120
|
+
|
121
|
+
# Go back and now add the new column where it hasn't been set
|
122
|
+
for library_export_path in config.library_export_paths:
|
123
|
+
with open(library_export_path, 'r', newline='', encoding='utf-8') as file:
|
124
|
+
reader = csv.DictReader(file)
|
125
|
+
field_names = list(reader.fieldnames) + [column_name]
|
126
|
+
file_content = [book_dict for book_dict in reader]
|
127
|
+
if not file_content or column_name in file_content[0]:
|
128
|
+
continue
|
129
|
+
|
130
|
+
with tempfile.NamedTemporaryFile(mode='w', delete=False, newline='') as temp_file:
|
131
|
+
temp_filename = temp_file.name
|
132
|
+
writer = csv.DictWriter(temp_file, fieldnames=field_names)
|
133
|
+
writer.writeheader()
|
134
|
+
|
135
|
+
for book_dict in file_content:
|
136
|
+
if is_tbr_book(book_dict):
|
137
|
+
title = get_book_title(book_dict)
|
138
|
+
authors = get_book_authors(book_dict)
|
139
|
+
key = f'{title}__{get_normalized_authors(authors)}'
|
140
|
+
|
141
|
+
if key in book_to_col_val_map:
|
142
|
+
col_val = book_to_col_val_map[key]
|
143
|
+
elif key in updated_book_map:
|
144
|
+
book = updated_book_map[key]
|
145
|
+
col_val = getattr(book, attr_name)
|
146
|
+
else:
|
147
|
+
col_val = ""
|
148
|
+
|
149
|
+
book_dict[column_name] = col_val
|
150
|
+
else:
|
151
|
+
book_dict[column_name] = ""
|
152
|
+
|
153
|
+
writer.writerow(book_dict)
|
154
|
+
|
155
|
+
shutil.move(temp_filename, library_export_path)
|
156
|
+
|
157
|
+
|
158
|
+
async def _maybe_set_library_export_audiobook_isbn(config: Config):
|
159
|
+
"""To get the price from Libro.fm for a book, you need its ISBN
|
160
|
+
|
161
|
+
As opposed to trying to get that every time latest-deals is run
|
162
|
+
we're just updating the export csv once to include the ISBN.
|
163
|
+
|
164
|
+
Unfortunately, we do have to get it at run time for wishlists.
|
165
|
+
"""
|
166
|
+
if "Libro.FM" not in config.tracked_retailers:
|
167
|
+
return
|
168
|
+
|
169
|
+
libro_fm = LibroFM()
|
170
|
+
await libro_fm.set_auth()
|
171
|
+
|
172
|
+
await _maybe_set_column_for_library_exports(
|
173
|
+
config,
|
174
|
+
"audiobook_isbn",
|
175
|
+
libro_fm.get_book_isbn,
|
176
|
+
)
|
177
|
+
|
178
|
+
|
179
|
+
async def _maybe_set_library_export_audiobook_list_price(config: Config):
|
180
|
+
"""Set a default list price for audiobooks
|
181
|
+
|
182
|
+
Only set if not currently set and the only audiobook retailer is Libro.FM
|
183
|
+
Libro.FM doesn't include the actual default price in its response, so this grabs the price reported by Chirp.
|
184
|
+
Chirp doesn't require a login to get this price info making it ideal in this instance.
|
185
|
+
|
186
|
+
:param config:
|
187
|
+
:return:
|
188
|
+
"""
|
189
|
+
if not requires_audiobook_list_price_default(config):
|
190
|
+
return
|
191
|
+
|
192
|
+
chirp = Chirp()
|
193
|
+
await chirp.set_auth()
|
194
|
+
|
195
|
+
await _maybe_set_column_for_library_exports(
|
196
|
+
config,
|
197
|
+
"list_price",
|
198
|
+
chirp.get_book,
|
199
|
+
"audiobook_list_price"
|
200
|
+
)
|
201
|
+
|
202
|
+
|
203
|
+
async def maybe_enrich_library_exports(config: Config):
|
204
|
+
await _maybe_set_library_export_audiobook_isbn(config)
|
205
|
+
await _maybe_set_library_export_audiobook_list_price(config)
|
@@ -1,4 +1,5 @@
|
|
1
1
|
import asyncio
|
2
|
+
import math
|
2
3
|
import os.path
|
3
4
|
from datetime import datetime
|
4
5
|
from textwrap import dedent
|
@@ -22,11 +23,9 @@ def login_url_callback(url: str) -> str:
|
|
22
23
|
|
23
24
|
try:
|
24
25
|
from playwright.sync_api import sync_playwright # type: ignore
|
25
|
-
use_playwright = True
|
26
26
|
except ImportError:
|
27
|
-
|
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
|
-
|
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
|
136
|
-
|
137
|
-
|
138
|
-
|
139
|
-
|
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
|
-
|
143
|
-
|
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
|
-
|
146
|
-
|
170
|
+
page += 1
|
171
|
+
total_pages = math.ceil(int(response.get("total_results", 1))/page_size)
|
172
|
+
|
173
|
+
return wishlist_books
|