tbr-deal-finder 0.1.7__py3-none-any.whl → 0.1.8__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
tbr_deal_finder/book.py CHANGED
@@ -1,8 +1,7 @@
1
- import dataclasses
2
1
  import re
3
2
  from datetime import datetime
4
3
  from enum import Enum
5
- from typing import Optional, Union
4
+ from typing import Union
6
5
 
7
6
  import click
8
7
  from Levenshtein import ratio
@@ -15,44 +14,45 @@ _AUTHOR_RE = re.compile(r'[^a-zA-Z0-9]')
15
14
 
16
15
  class BookFormat(Enum):
17
16
  AUDIOBOOK = "Audiobook"
18
- NA = "N/A" # When format does not matter
19
-
20
-
21
- @dataclasses.dataclass
22
- class Book:
23
- retailer: str
24
- title: str
25
- authors: str
26
- list_price: float
27
- current_price: float
28
- timepoint: datetime
29
- format: Union[BookFormat, str]
30
-
31
- # Metadata really only used for tracked books.
32
- # See get_tbr_books for more context
33
- audiobook_isbn: str = None
34
- audiobook_list_price: float = 0
35
-
36
- deleted: bool = False
37
-
38
- deal_id: Optional[str] = None
39
- exists: bool = True
40
- normalized_authors: list[str] = None
41
-
42
- def __post_init__(self):
43
- self.current_price = round(self.current_price, 2)
44
- self.list_price = round(self.list_price, 2)
45
- self.normalized_authors = get_normalized_authors(self.authors)
46
-
47
- # Strip the title down to its most basic repr
48
- # Improves hit rate on retailers
49
- self.title = self.title.split(":")[0].split("(")[0].strip()
50
-
51
- if not self.deal_id:
52
- self.deal_id = f"{self.title}__{self.normalized_authors}__{self.format}__{self.retailer}"
53
-
54
- if isinstance(self.format, str):
55
- self.format = BookFormat(self.format)
17
+ EBOOK = "E-Book"
18
+ NA = "N/A" # When the format doesn't matter
19
+
20
+
21
+ class Book:
22
+
23
+ def __init__(
24
+ self,
25
+ retailer: str,
26
+ title: str,
27
+ authors: str,
28
+ timepoint: datetime,
29
+ format: Union[BookFormat, str],
30
+ list_price: float = 0,
31
+ current_price: float = 0,
32
+ ebook_asin: str = None,
33
+ audiobook_isbn: str = None,
34
+ audiobook_list_price: float = 0,
35
+ deleted: bool = False,
36
+ exists: bool = True,
37
+ ):
38
+ self.retailer = retailer
39
+ self.title = title.split(":")[0].split("(")[0].strip()
40
+ self.authors = authors
41
+ self.timepoint = timepoint
42
+
43
+ self.ebook_asin = ebook_asin
44
+ self.audiobook_isbn = audiobook_isbn
45
+ self.audiobook_list_price = audiobook_list_price
46
+ self.deleted = deleted
47
+ self.exists = exists
48
+
49
+ self.list_price = list_price
50
+ self.current_price = current_price
51
+ self.normalized_authors = get_normalized_authors(authors)
52
+
53
+ if isinstance(format, str):
54
+ format = BookFormat(format)
55
+ self.format = format
56
56
 
57
57
  def discount(self) -> int:
58
58
  return int((self.list_price/self.current_price - 1) * 100)
@@ -61,6 +61,10 @@ class Book:
61
61
  def price_to_string(price: float) -> str:
62
62
  return f"{Config.currency_symbol()}{price:.2f}"
63
63
 
64
+ @property
65
+ def deal_id(self) -> str:
66
+ return f"{self.title}__{self.normalized_authors}__{self.format}__{self.retailer}"
67
+
64
68
  @property
65
69
  def title_id(self) -> str:
66
70
  return f"{self.title}__{self.normalized_authors}__{self.format}"
@@ -69,6 +73,22 @@ class Book:
69
73
  def full_title_str(self) -> str:
70
74
  return f"{self.title}__{self.normalized_authors}"
71
75
 
76
+ @property
77
+ def current_price(self) -> float:
78
+ return self._current_price
79
+
80
+ @current_price.setter
81
+ def current_price(self, price: float):
82
+ self._current_price = round(price, 2)
83
+
84
+ @property
85
+ def list_price(self) -> float:
86
+ return self._list_price
87
+
88
+ @list_price.setter
89
+ def list_price(self, price: float):
90
+ self._list_price = round(price, 2)
91
+
72
92
  def list_price_string(self):
73
93
  return self.price_to_string(self.list_price)
74
94
 
@@ -81,17 +101,31 @@ class Book:
81
101
  title = self.title
82
102
  if len(self.title) > 75:
83
103
  title = f"{title[:75]}..."
84
- return f"{title} by {self.authors} - {price} - {self.discount()}% Off at {self.retailer} - {book_format}"
104
+ return f"{title} by {self.authors} - {price} - {self.discount()}% Off at {self.retailer}"
85
105
 
86
106
  def dict(self):
87
- response = dataclasses.asdict(self)
88
- response["format"] = self.format.value
89
- del response["audiobook_isbn"]
90
- del response["audiobook_list_price"]
91
- del response["exists"]
92
- del response["normalized_authors"]
93
-
94
- return response
107
+ return {
108
+ "retailer": self.retailer,
109
+ "title": self.title,
110
+ "authors": self.authors,
111
+ "list_price": self.list_price,
112
+ "current_price": self.current_price,
113
+ "timepoint": self.timepoint,
114
+ "format": self.format.value,
115
+ "deleted": self.deleted,
116
+ "deal_id": self.deal_id,
117
+ }
118
+
119
+ def tbr_dict(self):
120
+ return {
121
+ "title": self.title,
122
+ "authors": self.authors,
123
+ "format": self.format.value,
124
+ "ebook_asin": self.ebook_asin,
125
+ "audiobook_isbn": self.audiobook_isbn,
126
+ "audiobook_list_price": self.audiobook_list_price,
127
+ "book_id": self.title_id,
128
+ }
95
129
 
96
130
 
97
131
  def get_deals_found_at(timepoint: datetime) -> list[Book]:
tbr_deal_finder/cli.py CHANGED
@@ -9,11 +9,11 @@ import click
9
9
  import questionary
10
10
 
11
11
  from tbr_deal_finder.config import Config
12
- from tbr_deal_finder.library_exports import maybe_enrich_library_exports
13
12
  from tbr_deal_finder.migrations import make_migrations
14
13
  from tbr_deal_finder.book import get_deals_found_at, print_books, get_active_deals
15
14
  from tbr_deal_finder.retailer import RETAILER_MAP
16
15
  from tbr_deal_finder.retailer_deal import get_latest_deals
16
+ from tbr_deal_finder.tracked_books import reprocess_incomplete_tbr_books
17
17
  from tbr_deal_finder.utils import (
18
18
  echo_err,
19
19
  echo_info,
@@ -180,6 +180,10 @@ def _set_config() -> Config:
180
180
  def setup():
181
181
  _set_config()
182
182
 
183
+ # Retailers may have changed causing some books to need reprocessing
184
+ config = Config.load()
185
+ reprocess_incomplete_tbr_books(config)
186
+
183
187
 
184
188
  @cli.command()
185
189
  def latest_deals():
@@ -189,8 +193,6 @@ def latest_deals():
189
193
  except FileNotFoundError:
190
194
  config = _set_config()
191
195
 
192
- asyncio.run(maybe_enrich_library_exports(config))
193
-
194
196
  db_conn = get_duckdb_conn()
195
197
  results = execute_query(
196
198
  db_conn,
tbr_deal_finder/config.py CHANGED
@@ -83,6 +83,16 @@ class Config:
83
83
  def tracked_retailers_str(self) -> str:
84
84
  return ", ".join(self.tracked_retailers)
85
85
 
86
+ def is_tracking_format(self, book_format) -> bool:
87
+ from tbr_deal_finder.retailer import RETAILER_MAP
88
+
89
+ for retailer_str in self.tracked_retailers:
90
+ retailer = RETAILER_MAP[retailer_str]()
91
+ if retailer.format == book_format:
92
+ return True
93
+
94
+ return False
95
+
86
96
  def set_library_export_paths(self, library_export_paths: Union[str, list[str]]):
87
97
  if not library_export_paths:
88
98
  self.library_export_paths = []
@@ -46,6 +46,22 @@ _MIGRATIONS = [
46
46
  );
47
47
  """
48
48
  ),
49
+ TableMigration(
50
+ version=1,
51
+ table_name="tbr_book",
52
+ sql="""
53
+ CREATE TABLE tbr_book
54
+ (
55
+ title VARCHAR,
56
+ authors VARCHAR,
57
+ format VARCHAR,
58
+ ebook_asin VARCHAR,
59
+ audiobook_isbn VARCHAR,
60
+ audiobook_list_price FLOAT,
61
+ book_id VARCHAR
62
+ );
63
+ """
64
+ ),
49
65
  ]
50
66
 
51
67
 
@@ -1,4 +1,4 @@
1
- SELECT *
1
+ SELECT * exclude(deal_id)
2
2
  FROM retailer_deal
3
3
  QUALIFY ROW_NUMBER() OVER (PARTITION BY title, authors, retailer, format ORDER BY timepoint DESC) = 1 AND deleted IS NOT TRUE
4
4
  ORDER BY title, authors, retailer, format
@@ -1,4 +1,4 @@
1
- SELECT *
1
+ SELECT * exclude(deal_id)
2
2
  FROM retailer_deal
3
3
  WHERE timepoint = $timepoint AND deleted IS NOT TRUE
4
4
  ORDER BY title, authors, retailer, format
@@ -0,0 +1,79 @@
1
+ import os.path
2
+
3
+ import audible
4
+ import click
5
+ from audible.login import build_init_cookies
6
+ from textwrap import dedent
7
+
8
+ from tbr_deal_finder import TBR_DEALS_PATH
9
+ from tbr_deal_finder.config import Config
10
+ from tbr_deal_finder.retailer.models import Retailer
11
+
12
+ _AUTH_PATH = TBR_DEALS_PATH.joinpath("audible.json")
13
+
14
+
15
+ def login_url_callback(url: str) -> str:
16
+ """Helper function for login with external browsers."""
17
+
18
+ try:
19
+ from playwright.sync_api import sync_playwright # type: ignore
20
+ except ImportError:
21
+ pass
22
+ else:
23
+ with sync_playwright() as p:
24
+ iphone = p.devices["iPhone 12 Pro"]
25
+ browser = p.webkit.launch(headless=False)
26
+ context = browser.new_context(
27
+ **iphone
28
+ )
29
+ cookies = []
30
+ for name, value in build_init_cookies().items():
31
+ cookies.append(
32
+ {
33
+ "name": name,
34
+ "value": value,
35
+ "url": url
36
+ }
37
+ )
38
+ context.add_cookies(cookies)
39
+ page = browser.new_page()
40
+ page.goto(url)
41
+
42
+ while True:
43
+ page.wait_for_timeout(600)
44
+ if "/ap/maplanding" in page.url:
45
+ response_url = page.url
46
+ break
47
+
48
+ browser.close()
49
+ return response_url
50
+
51
+ message = f"""\
52
+ Please copy the following url and insert it into a web browser of your choice to log into Amazon.
53
+ Note: your browser will show you an error page (Page not found). This is expected.
54
+
55
+ {url}
56
+
57
+ Once you have logged in, please insert the copied url.
58
+ """
59
+ click.echo(dedent(message))
60
+ return input()
61
+
62
+
63
+ class Amazon(Retailer):
64
+ _auth: audible.Authenticator = None
65
+ _client: audible.AsyncClient = None
66
+
67
+ async def set_auth(self):
68
+ if not os.path.exists(_AUTH_PATH):
69
+ auth = audible.Authenticator.from_login_external(
70
+ locale=Config.locale,
71
+ login_url_callback=login_url_callback
72
+ )
73
+
74
+ # Save credentials to file
75
+ auth.to_file(_AUTH_PATH)
76
+
77
+ self._auth = audible.Authenticator.from_file(_AUTH_PATH)
78
+ self._client = audible.AsyncClient(auth=self._auth)
79
+
@@ -1,74 +1,13 @@
1
1
  import asyncio
2
2
  import math
3
- import os.path
4
- from datetime import datetime
5
- from textwrap import dedent
6
3
  import readline # type: ignore
7
4
 
8
-
9
- import audible
10
- import click
11
- from audible.login import build_init_cookies
12
-
13
- from tbr_deal_finder import TBR_DEALS_PATH
14
5
  from tbr_deal_finder.config import Config
15
- from tbr_deal_finder.retailer.models import Retailer
6
+ from tbr_deal_finder.retailer.amazon import Amazon
16
7
  from tbr_deal_finder.book import Book, BookFormat
17
8
 
18
- _AUTH_PATH = TBR_DEALS_PATH.joinpath("audible.json")
19
-
20
-
21
- def login_url_callback(url: str) -> str:
22
- """Helper function for login with external browsers."""
23
-
24
- try:
25
- from playwright.sync_api import sync_playwright # type: ignore
26
- except ImportError:
27
- pass
28
- else:
29
- with sync_playwright() as p:
30
- iphone = p.devices["iPhone 12 Pro"]
31
- browser = p.webkit.launch(headless=False)
32
- context = browser.new_context(
33
- **iphone
34
- )
35
- cookies = []
36
- for name, value in build_init_cookies().items():
37
- cookies.append(
38
- {
39
- "name": name,
40
- "value": value,
41
- "url": url
42
- }
43
- )
44
- context.add_cookies(cookies)
45
- page = browser.new_page()
46
- page.goto(url)
47
-
48
- while True:
49
- page.wait_for_timeout(600)
50
- if "/ap/maplanding" in page.url:
51
- response_url = page.url
52
- break
53
-
54
- browser.close()
55
- return response_url
56
-
57
- message = f"""\
58
- Please copy the following url and insert it into a web browser of your choice to log into Amazon.
59
- Note: your browser will show you an error page (Page not found). This is expected.
60
-
61
- {url}
62
-
63
- Once you have logged in, please insert the copied url.
64
- """
65
- click.echo(dedent(message))
66
- return input()
67
9
 
68
-
69
- class Audible(Retailer):
70
- _auth: audible.Authenticator = None
71
- _client: audible.AsyncClient = None
10
+ class Audible(Amazon):
72
11
 
73
12
  @property
74
13
  def name(self) -> str:
@@ -78,23 +17,9 @@ class Audible(Retailer):
78
17
  def format(self) -> BookFormat:
79
18
  return BookFormat.AUDIOBOOK
80
19
 
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
-
94
20
  async def get_book(
95
21
  self,
96
22
  target: Book,
97
- runtime: datetime,
98
23
  semaphore: asyncio.Semaphore
99
24
  ) -> Book:
100
25
  title = target.title
@@ -114,27 +39,16 @@ class Audible(Retailer):
114
39
  for product in match.get("products", []):
115
40
  if product["title"] != title:
116
41
  continue
42
+ try:
43
+ target.list_price = product["price"]["list_price"]["base"]
44
+ target.current_price = product["price"]["lowest_price"]["base"]
45
+ target.exists = True
46
+ return target
47
+ except KeyError:
48
+ continue
117
49
 
118
- return Book(
119
- retailer=self.name,
120
- title=title,
121
- authors=authors,
122
- list_price=product["price"]["list_price"]["base"],
123
- current_price=product["price"]["lowest_price"]["base"],
124
- timepoint=runtime,
125
- format=BookFormat.AUDIOBOOK
126
- )
127
-
128
- return Book(
129
- retailer=self.name,
130
- title=title,
131
- authors=authors,
132
- list_price=0,
133
- current_price=0,
134
- timepoint=runtime,
135
- format=BookFormat.AUDIOBOOK,
136
- exists=False,
137
- )
50
+ target.exists = False
51
+ return target
138
52
 
139
53
  async def get_wishlist(self, config: Config) -> list[Book]:
140
54
  wishlist_books = []
@@ -84,10 +84,9 @@ class Chirp(Retailer):
84
84
  json.dump(response, f)
85
85
 
86
86
  async def get_book(
87
- self, target: Book, runtime: datetime, semaphore: asyncio.Semaphore
87
+ self, target: Book, semaphore: asyncio.Semaphore
88
88
  ) -> Book:
89
89
  title = target.title
90
- authors = target.authors
91
90
  async with semaphore:
92
91
  async with aiohttp.ClientSession() as http_client:
93
92
  response = await http_client.request(
@@ -103,16 +102,8 @@ class Chirp(Retailer):
103
102
 
104
103
  audiobooks = response_body["data"]["audiobooks"]["objects"]
105
104
  if not audiobooks:
106
- return Book(
107
- retailer=self.name,
108
- title=title,
109
- authors=authors,
110
- list_price=0,
111
- current_price=0,
112
- timepoint=runtime,
113
- format=BookFormat.AUDIOBOOK,
114
- exists=False,
115
- )
105
+ target.exists = False
106
+ return target
116
107
 
117
108
  for book in audiobooks:
118
109
  if not book["currentProduct"]:
@@ -123,26 +114,12 @@ class Chirp(Retailer):
123
114
  book["displayTitle"] == title
124
115
  and is_matching_authors(target.normalized_authors, normalized_authors)
125
116
  ):
126
- return Book(
127
- retailer=self.name,
128
- title=title,
129
- authors=authors,
130
- list_price=currency_to_float(book["currentProduct"]["listingPrice"]),
131
- current_price=currency_to_float(book["currentProduct"]["discountPrice"]),
132
- timepoint=runtime,
133
- format=BookFormat.AUDIOBOOK,
134
- )
117
+ target.list_price = currency_to_float(book["currentProduct"]["listingPrice"])
118
+ target.current_price = currency_to_float(book["currentProduct"]["discountPrice"])
119
+ return target
135
120
 
136
- return Book(
137
- retailer=self.name,
138
- title=title,
139
- authors=target.authors,
140
- list_price=0,
141
- current_price=0,
142
- timepoint=runtime,
143
- format=BookFormat.AUDIOBOOK,
144
- exists=False,
145
- )
121
+ target.exists = False
122
+ return target
146
123
 
147
124
  async def get_wishlist(self, config: Config) -> list[Book]:
148
125
  wishlist_books = []
@@ -76,11 +76,11 @@ class LibroFM(Retailer):
76
76
  "password": click.prompt("Libro FM Password", hide_input=True),
77
77
  }
78
78
  )
79
- self.auth_token = response
79
+ self.auth_token = response["access_token"]
80
80
  with open(auth_path, "w") as f:
81
81
  json.dump(response, f)
82
82
 
83
- async def get_book_isbn(self, book: Book, runtime: datetime, semaphore: asyncio.Semaphore) -> Book:
83
+ async def get_book_isbn(self, book: Book, semaphore: asyncio.Semaphore) -> Book:
84
84
  # runtime isn't used but get_book_isbn must follow the get_book method signature.
85
85
 
86
86
  title = book.title
@@ -109,24 +109,11 @@ class LibroFM(Retailer):
109
109
  return book
110
110
 
111
111
  async def get_book(
112
- self, target: Book, runtime: datetime, semaphore: asyncio.Semaphore
112
+ self, target: Book, semaphore: asyncio.Semaphore
113
113
  ) -> Book:
114
- if target.format == BookFormat.AUDIOBOOK and not target.audiobook_isbn:
115
- # When "format" is AUDIOBOOK here that means the target was pulled from an audiobook retailer wishlist
116
- # In this flow, there is no attempt to resolve the isbn ahead of time, so it's done here instead.
117
- await self.get_book_isbn(target, runtime, semaphore)
118
-
119
114
  if not target.audiobook_isbn:
120
- return Book(
121
- retailer=self.name,
122
- title=target.title,
123
- authors=target.authors,
124
- list_price=0,
125
- current_price=0,
126
- timepoint=runtime,
127
- format=BookFormat.AUDIOBOOK,
128
- exists=False,
129
- )
115
+ target.exists = False
116
+ return target
130
117
 
131
118
  async with semaphore:
132
119
  response = await self.make_request(
@@ -135,26 +122,12 @@ class LibroFM(Retailer):
135
122
  )
136
123
 
137
124
  if response:
138
- return Book(
139
- retailer=self.name,
140
- title=target.title,
141
- authors=target.authors,
142
- list_price=target.audiobook_list_price,
143
- current_price=currency_to_float(response["data"]["purchase_info"]["price"]),
144
- timepoint=runtime,
145
- format=BookFormat.AUDIOBOOK,
146
- )
125
+ target.list_price = target.audiobook_list_price
126
+ target.current_price = currency_to_float(response["data"]["purchase_info"]["price"])
127
+ return target
147
128
 
148
- return Book(
149
- retailer=self.name,
150
- title=target.title,
151
- authors=target.authors,
152
- list_price=0,
153
- current_price=0,
154
- timepoint=runtime,
155
- format=BookFormat.AUDIOBOOK,
156
- exists=False,
157
- )
129
+ target.exists = False
130
+ return target
158
131
 
159
132
  async def get_wishlist(self, config: Config) -> list[Book]:
160
133
  wishlist_books = []
@@ -1,6 +1,5 @@
1
1
  import abc
2
2
  import asyncio
3
- from datetime import datetime
4
3
 
5
4
  from tbr_deal_finder.book import Book, BookFormat
6
5
  from tbr_deal_finder.config import Config
@@ -29,7 +28,7 @@ class Retailer(abc.ABC):
29
28
  raise NotImplementedError
30
29
 
31
30
  async def get_book(
32
- self, target: Book, runtime: datetime, semaphore: asyncio.Semaphore
31
+ self, target: Book, semaphore: asyncio.Semaphore
33
32
  ) -> Book:
34
33
  """Get book information from the retailer.
35
34
 
@@ -8,7 +8,6 @@ from tqdm.asyncio import tqdm_asyncio
8
8
 
9
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.owned_books import get_owned_books
12
11
  from tbr_deal_finder.tracked_books import get_tbr_books
13
12
  from tbr_deal_finder.retailer import RETAILER_MAP
14
13
  from tbr_deal_finder.retailer.models import Retailer
@@ -22,9 +21,9 @@ def update_retailer_deal_table(config: Config, new_deals: list[Book]):
22
21
  :param new_deals:
23
22
  """
24
23
 
25
- # This could be done using a temp table for the new deals, but that feels like overkill
26
- # I can't imagine there's ever going to be more than 5,000 books in someone's TBR
27
- # If it were any larger we'd have bigger problems.
24
+ # This could be done using a temp table for the new deals, but that feels like overkill.
25
+ # I can't imagine there's ever going to be more than 5,000 books in someone's TBR.
26
+ # If it were any larger, we'd have bigger problems.
28
27
  active_deal_map = {deal.deal_id: deal for deal in get_active_deals()}
29
28
  # Dirty trick to ensure uniqueness in request
30
29
  new_deals = list({nd.deal_id: nd for nd in new_deals}.values())
@@ -74,9 +73,13 @@ async def _get_books(config, retailer: Retailer, books: list[Book]) -> list[Book
74
73
  semaphore = asyncio.Semaphore(10)
75
74
  response = []
76
75
  unresolved_books = []
76
+ books = [copy.deepcopy(book) for book in books]
77
+ for book in books:
78
+ book.retailer = retailer.name
79
+ book.format = retailer.format
77
80
 
78
81
  tasks = [
79
- retailer.get_book(copy.deepcopy(book), config.run_time, semaphore)
82
+ retailer.get_book(book, semaphore)
80
83
  for book in books
81
84
  ]
82
85
  results = await tqdm_asyncio.gather(*tasks, desc=f"Getting latest prices from {retailer.name}")
@@ -130,26 +133,19 @@ def _apply_proper_list_prices(books: list[Book]):
130
133
  def _get_retailer_relevant_tbr_books(
131
134
  retailer: Retailer,
132
135
  books: list[Book],
133
- owned_book_title_map: dict[str, dict[BookFormat, Book]],
134
136
  ) -> list[Book]:
135
137
  """
136
138
  Don't check on deals in a specified format that does not match the format the retailer sells.
137
- Also, don't check on deals for a book if a copy is already owned in that same format.
138
139
 
139
140
  :param retailer:
140
141
  :param books:
141
- :param owned_book_title_map:
142
142
  :return:
143
143
  """
144
144
 
145
145
  response = []
146
146
 
147
147
  for book in books:
148
- owned_versions = owned_book_title_map[book.full_title_str]
149
- if (
150
- (book.format == BookFormat.NA or book.format == retailer.format)
151
- and retailer.format not in owned_versions
152
- ):
148
+ if book.format == BookFormat.NA or book.format == retailer.format:
153
149
  response.append(book)
154
150
 
155
151
  return response
@@ -174,11 +170,6 @@ async def get_latest_deals(config: Config):
174
170
 
175
171
  books: list[Book] = []
176
172
  tbr_books = await get_tbr_books(config)
177
- owned_books = await get_owned_books(config)
178
-
179
- owned_book_title_map: dict[str, dict[BookFormat, Book]] = defaultdict(dict)
180
- for book in owned_books:
181
- owned_book_title_map[book.full_title_str][book.format] = book
182
173
 
183
174
  for retailer_str in config.tracked_retailers:
184
175
  retailer = RETAILER_MAP[retailer_str]()
@@ -187,7 +178,6 @@ async def get_latest_deals(config: Config):
187
178
  relevant_tbr_books = _get_retailer_relevant_tbr_books(
188
179
  retailer,
189
180
  tbr_books,
190
- owned_book_title_map,
191
181
  )
192
182
 
193
183
  echo_info(f"Getting deals from {retailer.name}")
@@ -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.retailer import Chirp, RETAILER_MAP
11
+ from tbr_deal_finder.owned_books import get_owned_books
12
+ from tbr_deal_finder.retailer import Chirp, RETAILER_MAP, LibroFM
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 currency_to_float
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 and tbr_book_map[key].audiobook_isbn:
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,244 @@ 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
- async def get_tbr_books(config: Config) -> list[Book]:
110
- tbr_book_map: dict[str: Book] = {}
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)
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)
111
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
+ )
193
+
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
+ As opposed to trying to get that every time latest-deals is run
199
+ we're just updating the export csv once to include the ISBN.
200
+
201
+ """
202
+ if "Libro.FM" not in config.tracked_retailers:
203
+ return
204
+
205
+ libro_fm = LibroFM()
206
+ await libro_fm.set_auth()
207
+
208
+ relevant_tbr_books = [
209
+ book
210
+ for book in new_tbr_books
211
+ if book.format in [BookFormat.AUDIOBOOK, BookFormat.NA]
212
+ ]
213
+
214
+ await _set_tbr_book_attr(
215
+ relevant_tbr_books,
216
+ "audiobook_isbn",
217
+ libro_fm.get_book_isbn,
218
+ )
219
+
220
+
221
+ def get_book_authors(book: dict) -> str:
222
+ if authors := book.get('Authors'):
223
+ return authors
224
+
225
+ authors = book['Author']
226
+ if additional_authors := book.get("Additional Authors"):
227
+ authors = f"{authors}, {additional_authors}"
228
+
229
+ return authors
230
+
231
+
232
+ def get_book_title(book: dict) -> str:
233
+ title = book['Title']
234
+ return title.split("(")[0].strip()
235
+
236
+
237
+ def is_tbr_book(book: dict) -> bool:
238
+ if "Read Status" in book:
239
+ return book["Read Status"] == "to-read"
240
+ elif "Bookshelves" in book:
241
+ return "to-read" in book["Bookshelves"]
242
+ else:
243
+ return True
117
244
 
118
- return list(tbr_book_map.values())
119
245
 
246
+ def reprocess_incomplete_tbr_books(config: Config):
247
+ db_conn = get_duckdb_conn()
248
+
249
+ if config.is_tracking_format(BookFormat.EBOOK):
250
+ # Replace any tbr_books missing required attr
251
+ db_conn.execute(
252
+ "DELETE FROM tbr_book WHERE ebook_asin IS NULL AND format != $book_format",
253
+ parameters=dict(book_format=BookFormat.AUDIOBOOK.value)
254
+ )
255
+
256
+ if LibroFM().name in config.tracked_retailers:
257
+ # Replace any tbr_books missing required attr
258
+ db_conn.execute(
259
+ "DELETE FROM tbr_book WHERE audiobook_isbn IS NULL AND format != $book_format",
260
+ parameters=dict(book_format=BookFormat.EBOOK.value)
261
+ )
262
+
263
+ if _requires_audiobook_list_price(config):
264
+ # Replace any tbr_books missing required attr
265
+ db_conn.execute(
266
+ "DELETE FROM tbr_book WHERE audiobook_list_price IS NULL AND format != $book_format",
267
+ parameters=dict(book_format=BookFormat.EBOOK.value)
268
+ )
269
+
270
+
271
+ async def sync_tbr_books(config: Config):
272
+ raw_tbr_books = await _get_raw_tbr_books(config)
273
+ db_conn = get_duckdb_conn()
274
+
275
+ if not raw_tbr_books:
276
+ return
277
+
278
+ df = pd.DataFrame([book.tbr_dict() for book in raw_tbr_books])
279
+ db_conn.register("_df", df)
280
+ db_conn.execute("CREATE OR REPLACE TABLE _latest_tbr_book AS SELECT * FROM _df;")
281
+ db_conn.unregister("_df")
282
+
283
+ # Remove books no longer on user tbr
284
+ db_conn.execute(
285
+ "DELETE FROM tbr_book WHERE book_id NOT IN (SELECT book_id FROM _latest_tbr_book)"
286
+ )
287
+
288
+ # Remove books from _latest_tbr_book for further processing for books already in tbr_book
289
+ db_conn.execute(
290
+ "DELETE FROM _latest_tbr_book WHERE book_id IN (SELECT book_id FROM tbr_book)"
291
+ )
292
+
293
+ new_tbr_book_data = execute_query(
294
+ db_conn,
295
+ "SELECT * EXCLUDE(book_id) FROM _latest_tbr_book"
296
+ )
297
+
298
+ new_tbr_books = [Book(retailer="N/A", timepoint=config.run_time, **b) for b in new_tbr_book_data]
299
+ if not new_tbr_books:
300
+ return
301
+
302
+ await _maybe_set_audiobook_list_price(config, new_tbr_books)
303
+ await _maybe_set_audiobook_isbn(config, new_tbr_books)
304
+
305
+ df = pd.DataFrame([book.tbr_dict() for book in new_tbr_books])
306
+ db_conn.register("_df", df)
307
+ db_conn.execute("INSERT INTO tbr_book SELECT * FROM _df;")
308
+ db_conn.unregister("_df")
309
+
310
+
311
+ async def get_tbr_books(config: Config) -> list[Book]:
312
+ await sync_tbr_books(config)
313
+
314
+ db_conn = get_duckdb_conn()
315
+ tbr_book_data = execute_query(
316
+ db_conn,
317
+ "SELECT * EXCLUDE(book_id) FROM tbr_book"
318
+ )
120
319
 
320
+ 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.1.7
3
+ Version: 0.1.8
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
@@ -56,7 +56,7 @@ Track price drops and find deals on books in your TBR (To Be Read) and wishlist
56
56
  1. If it's not already on your computer, download Python https://www.python.org/downloads/
57
57
  1. tbr-deal-finder requires Python3.13 or higher
58
58
  2. Optional: Install and use virtualenv
59
- 3. Open your Terminal/Commmand Prompt
59
+ 3. Open your Terminal/Command Prompt
60
60
  4. Run `pip3.13 install tbr-deal-finder`
61
61
 
62
62
  ### UV
@@ -0,0 +1,23 @@
1
+ tbr_deal_finder/__init__.py,sha256=WCoj0GZrRiCQlrpkLTw1VUeJmX-RtBLdLqnFYn1Es_4,208
2
+ tbr_deal_finder/book.py,sha256=MYdZ7WeSo9hWy9Af7T5U3-7zPgi5qdMPqlNtRYPMLck,5492
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=SOmygViADjr8fa3RiB4dn_bWjIpiAOBNQK37MHOu7nE,10407
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=WePMSN7vi4EL_uPiAH6ogNNE-kRQe4OHT4CYGTKvBSk,243
14
+ tbr_deal_finder/retailer/amazon.py,sha256=-U28l3LmVCbOvK0SIXG6MAfYgoRUA--enNQJBxA_NHM,2322
15
+ tbr_deal_finder/retailer/audible.py,sha256=1zM95teGth4UPyK5ZogLM9SIQxVd3kTgjTEiglnQKR8,3979
16
+ tbr_deal_finder/retailer/chirp.py,sha256=s5aj3NwMA1GSymmTylnoWghQsqMp7hFcMdnoA1TRsv4,9241
17
+ tbr_deal_finder/retailer/librofm.py,sha256=ARkUlxMS3OCgfKosX_XKVmMfLcIIDRPzqqmVp0V9IZA,6327
18
+ tbr_deal_finder/retailer/models.py,sha256=I_U8VC5AOozGfPecW7FW8eRoTWEOxnMIHPNbuOJWcjw,1463
19
+ tbr_deal_finder-0.1.8.dist-info/METADATA,sha256=4urAR2x-ABDN0OMscyowYP8Gqc_RXlVsYJoaRxpF7m8,4342
20
+ tbr_deal_finder-0.1.8.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
21
+ tbr_deal_finder-0.1.8.dist-info/entry_points.txt,sha256=y_KG1k8xVCY8gngSZ-na2bkK-tTLUdOc_qZ9Djwldv0,60
22
+ tbr_deal_finder-0.1.8.dist-info/licenses/LICENSE,sha256=rNc0wNPn4d4HHu6ZheJzeUaz_FbJ4rj2Dr2FjAivkNg,1064
23
+ tbr_deal_finder-0.1.8.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,,