tbr-deal-finder 0.1.5__tar.gz → 0.1.7__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.
Files changed (31) hide show
  1. tbr_deal_finder-0.1.7/CHANGELOG.md +39 -0
  2. tbr_deal_finder-0.1.7/DESIGN.md +113 -0
  3. {tbr_deal_finder-0.1.5 → tbr_deal_finder-0.1.7}/PKG-INFO +9 -6
  4. {tbr_deal_finder-0.1.5 → tbr_deal_finder-0.1.7}/README.md +7 -5
  5. {tbr_deal_finder-0.1.5 → tbr_deal_finder-0.1.7}/pyproject.toml +2 -1
  6. {tbr_deal_finder-0.1.5 → tbr_deal_finder-0.1.7}/tbr_deal_finder/book.py +44 -5
  7. {tbr_deal_finder-0.1.5 → tbr_deal_finder-0.1.7}/tbr_deal_finder/cli.py +31 -27
  8. {tbr_deal_finder-0.1.5 → tbr_deal_finder-0.1.7}/tbr_deal_finder/config.py +10 -2
  9. tbr_deal_finder-0.1.7/tbr_deal_finder/library_exports.py +208 -0
  10. tbr_deal_finder-0.1.7/tbr_deal_finder/owned_books.py +18 -0
  11. {tbr_deal_finder-0.1.5 → tbr_deal_finder-0.1.7}/tbr_deal_finder/retailer/audible.py +89 -26
  12. tbr_deal_finder-0.1.7/tbr_deal_finder/retailer/chirp.py +241 -0
  13. {tbr_deal_finder-0.1.5 → tbr_deal_finder-0.1.7}/tbr_deal_finder/retailer/librofm.py +90 -8
  14. {tbr_deal_finder-0.1.5 → tbr_deal_finder-0.1.7}/tbr_deal_finder/retailer/models.py +20 -2
  15. {tbr_deal_finder-0.1.5 → tbr_deal_finder-0.1.7}/tbr_deal_finder/retailer_deal.py +50 -28
  16. tbr_deal_finder-0.1.7/tbr_deal_finder/tracked_books.py +120 -0
  17. {tbr_deal_finder-0.1.5 → tbr_deal_finder-0.1.7}/uv.lock +52 -1
  18. tbr_deal_finder-0.1.5/CHANGELOG.md +0 -12
  19. tbr_deal_finder-0.1.5/tbr_deal_finder/library_exports.py +0 -155
  20. tbr_deal_finder-0.1.5/tbr_deal_finder/retailer/chirp.py +0 -80
  21. {tbr_deal_finder-0.1.5 → tbr_deal_finder-0.1.7}/.github/workflows/publish-to-pypi.yaml +0 -0
  22. {tbr_deal_finder-0.1.5 → tbr_deal_finder-0.1.7}/.gitignore +0 -0
  23. {tbr_deal_finder-0.1.5 → tbr_deal_finder-0.1.7}/.python-version +0 -0
  24. {tbr_deal_finder-0.1.5 → tbr_deal_finder-0.1.7}/LICENSE +0 -0
  25. {tbr_deal_finder-0.1.5 → tbr_deal_finder-0.1.7}/tbr_deal_finder/__init__.py +0 -0
  26. {tbr_deal_finder-0.1.5 → tbr_deal_finder-0.1.7}/tbr_deal_finder/migrations.py +0 -0
  27. {tbr_deal_finder-0.1.5 → tbr_deal_finder-0.1.7}/tbr_deal_finder/queries/get_active_deals.sql +0 -0
  28. {tbr_deal_finder-0.1.5 → tbr_deal_finder-0.1.7}/tbr_deal_finder/queries/get_deals_found_at.sql +0 -0
  29. {tbr_deal_finder-0.1.5 → tbr_deal_finder-0.1.7}/tbr_deal_finder/queries/latest_deal_last_ran_most_recent_success.sql +0 -0
  30. {tbr_deal_finder-0.1.5 → tbr_deal_finder-0.1.7}/tbr_deal_finder/retailer/__init__.py +0 -0
  31. {tbr_deal_finder-0.1.5 → tbr_deal_finder-0.1.7}/tbr_deal_finder/utils.py +0 -0
@@ -0,0 +1,39 @@
1
+
2
+ # Change Log
3
+
4
+ ## 0.1.7 (July 31, 2025)
5
+
6
+ Notes:
7
+ * tbr-deal-finder no longer shows deals on books you own in the same format.
8
+ * Example: You own Dune on Audible so it won't show on Audible, Libro, or Chirp. It will show on Kindle (you don't own the ebook)
9
+ * Improvements when attempting to match authors
10
+ * Chirp
11
+ * Libro.FM
12
+ * Users no longer need to provide an export and can instead just track deals on their wishlist
13
+
14
+ BUG FIXES:
15
+ * Fixed wishlist pagination in libro.fm
16
+ * Fixed issue forcing user to go through setup twice when running the setup command
17
+
18
+ ---
19
+
20
+ ## 0.1.6 (July 30, 2025)
21
+
22
+ Notes:
23
+ * tbr-deal-finder now also tracks deals on the books in your wishlist. Works for all retailers.
24
+
25
+ BUG FIXES:
26
+ * Fixed issue where no deals would display if libro is the only tracked audiobook retailer.
27
+ * Fixed retailer cli setup forcing a user to select at least two audiobook retailers.
28
+
29
+ ---
30
+
31
+ ## 0.1.5 (July 30, 2025)
32
+
33
+ Notes:
34
+ * Added formatting to select messages to make the messages purpose clearer.
35
+
36
+ BUG FIXES:
37
+ * Fixed issue getting books from libro and chirp too aggressively
38
+ * User must now track deals for at least one retailer
39
+
@@ -0,0 +1,113 @@
1
+ # TBR Deal Finder Design
2
+
3
+ ## Overview
4
+ TBR Deal Finder is a CLI application that helps users track price drops and find deals on books in their To-Be-Read (TBR) list across various digital book retailers. The application is built with a modular architecture that separates data ingestion, processing, and output concerns.
5
+
6
+ ## Terms
7
+ ### Digital Book
8
+ A book in electronic format, accessible via digital devices. Includes:
9
+ - Audiobooks
10
+ - E-books
11
+
12
+ ### Digital Book Retailer
13
+ Platforms that sell or distribute digital books, such as:
14
+ - Libro.fm (audiobooks)
15
+ - Audible (audiobooks)
16
+ - Kindle (e-books)
17
+ - Chirp (discounted audiobooks)
18
+
19
+ ### Library Export
20
+ A data file containing a user's reading list and status. Sources include:
21
+ - **Automated Exports**:
22
+ - The StoryGraph
23
+ - Goodreads
24
+ - **Manual CSVs**: Custom spreadsheets following the required format
25
+
26
+ ### TBR (To Be Read)
27
+ A user's personal reading list of books they plan to read in the future.
28
+
29
+ ## Core Components
30
+
31
+ ### 1. Data Ingestion Layer
32
+ - **Library Exports**: Handles importing book data from multiple sources:
33
+ - The StoryGraph exports
34
+ - Goodreads exports
35
+ - Custom CSV files
36
+ - **Retailer APIs**: Interfaces with various digital book retailers to fetch current pricing and availability
37
+
38
+ ### 2. Core Data Model
39
+
40
+ #### `Book` Class
41
+ The central data structure that represents a book across the application:
42
+ - **Purpose**: Serves as a consistent contract between different components
43
+ - **Key Attributes**:
44
+ - Title and author information
45
+ - Format (Audiobook, Ebook, etc.)
46
+ - Pricing information (list price, current price)
47
+ - Retailer-specific metadata
48
+ - Timestamp for deal tracking
49
+
50
+ ### 3. Retailer Interface
51
+
52
+ #### `Retailer` Base Class
53
+ Abstract base class that defines the interface for all retailer implementations:
54
+ - **Core Methods**:
55
+ - `get_book()`: Fetches book details from the retailer
56
+ - `get_wishlist()`: Retrieves the user's wishlist
57
+ - `get_library()`: Gets the user's purchased/owned books
58
+ - **Current Implementations**:
59
+ - Audible
60
+ - Libro.fm
61
+ - Chirp
62
+ - Kindle (planned)
63
+
64
+ ### 4. Processing Pipeline
65
+ 1. **Data Collection**:
66
+ - Load TBR lists from configured sources
67
+ - Fetch owned books from retailers
68
+ - Retrieve current deals and pricing
69
+
70
+ 2. **Matching Logic**:
71
+ - Matches TBR books with retailer inventory
72
+ - Filters out owned books
73
+ - Identifies price drops and deals
74
+
75
+ 3. **Output Generation**:
76
+ - Formats results for CLI display
77
+ - Highlights best deals
78
+ - Provides purchase links
79
+
80
+ ## Data Flow
81
+
82
+ ```mermaid
83
+ graph TD
84
+ A[Library Exports] -->|CSV/JSON| B[Book Objects]
85
+ C[Retailer APIs] -->|API Calls| B
86
+ B --> D[Deal Processing]
87
+ D --> E[CLI Output]
88
+ F[User Configuration] -->|Settings| C
89
+ F -->|Preferences| D
90
+ ```
91
+
92
+ ## Key Design Decisions
93
+
94
+ ### 1. Extensibility
95
+ - Retailer implementations are pluggable through the `Retailer` base class
96
+ - Support for multiple export formats and retailer APIs
97
+
98
+ ### 2. Performance
99
+ - Asynchronous I/O for API calls
100
+ - Caching of retailer responses
101
+ - Efficient data structures for book matching
102
+
103
+ ### 3. User Experience
104
+ - Clear, concise command-line interface
105
+ - Configurable output formats
106
+ - Progress indicators for long-running operations
107
+
108
+ ## Future Considerations
109
+ - Support for more retailer APIs
110
+ - Email notifications for price drops
111
+ - Web interface for easier configuration
112
+ - Integration with library lending services
113
+ - Price history tracking
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tbr-deal-finder
3
- Version: 0.1.5
3
+ Version: 0.1.7
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
@@ -9,6 +9,7 @@ Requires-Dist: aiohttp>=3.12.14
9
9
  Requires-Dist: audible==0.8.2
10
10
  Requires-Dist: click>=8.2.1
11
11
  Requires-Dist: duckdb>=1.3.2
12
+ Requires-Dist: levenshtein>=0.27.1
12
13
  Requires-Dist: pandas>=2.3.1
13
14
  Requires-Dist: questionary>=2.1.0
14
15
  Requires-Dist: tqdm>=4.67.1
@@ -17,15 +18,17 @@ Description-Content-Type: text/markdown
17
18
 
18
19
  # tbr-deal-finder
19
20
 
20
- Track price drops and find deals on books in your TBR (To Be Read) list across audiobook and ebook formats.
21
+ Track price drops and find deals on books in your TBR (To Be Read) and wishlist across digital book retailers.
21
22
 
22
23
  ## Features
23
- - Uses your StoryGraph exports, Goodreads exports, and custom csvs (spreadsheet) to track book deals
24
- - Supports multiple of the library exports above
24
+ - Use your StoryGraph exports, Goodreads exports, and custom csvs (spreadsheet) to track book deals
25
+ - Supports multiple of the library exports above
26
+ - Tracks deals on the wishlist of all your configured retailers like audible
25
27
  - Supports multiple locales and currencies
26
- - Finds the latest and active deals from supported sellers
28
+ - Find the latest and active deals from supported sellers
27
29
  - Simple CLI interface for setup and usage
28
30
  - Only get notified for new deals or view all active deals
31
+ - Filters out books you already own to prevent purchasing the same book on multiple retailers
29
32
 
30
33
  ## Support
31
34
 
@@ -66,7 +69,7 @@ Track price drops and find deals on books in your TBR (To Be Read) list across a
66
69
  https://docs.astral.sh/uv/getting-started/installation/
67
70
 
68
71
  ## Configuration
69
- This tool relies on the csv generated by the app you use to track your TBRs.
72
+ This tool can use the csv generated by the app you use to track your TBRs.
70
73
  Here are the steps to get your export.
71
74
 
72
75
  ### StoryGraph
@@ -1,14 +1,16 @@
1
1
  # tbr-deal-finder
2
2
 
3
- Track price drops and find deals on books in your TBR (To Be Read) list across audiobook and ebook formats.
3
+ Track price drops and find deals on books in your TBR (To Be Read) and wishlist across digital book retailers.
4
4
 
5
5
  ## Features
6
- - Uses your StoryGraph exports, Goodreads exports, and custom csvs (spreadsheet) to track book deals
7
- - Supports multiple of the library exports above
6
+ - Use your StoryGraph exports, Goodreads exports, and custom csvs (spreadsheet) to track book deals
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
- - Finds the latest and active deals from supported sellers
10
+ - Find the latest and active deals from supported sellers
10
11
  - Simple CLI interface for setup and usage
11
12
  - Only get notified for new deals or view all active deals
13
+ - Filters out books you already own to prevent purchasing the same book on multiple retailers
12
14
 
13
15
  ## Support
14
16
 
@@ -49,7 +51,7 @@ Track price drops and find deals on books in your TBR (To Be Read) list across a
49
51
  https://docs.astral.sh/uv/getting-started/installation/
50
52
 
51
53
  ## Configuration
52
- This tool relies on the csv generated by the app you use to track your TBRs.
54
+ This tool can use the csv generated by the app you use to track your TBRs.
53
55
  Here are the steps to get your export.
54
56
 
55
57
  ### StoryGraph
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "tbr-deal-finder"
3
- version = "0.1.5"
3
+ version = "0.1.7"
4
4
  description = "Track price drops and find deals on books in your TBR list across audiobook and ebook formats."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.13"
@@ -10,6 +10,7 @@ dependencies = [
10
10
  "audible==0.8.2",
11
11
  "click>=8.2.1",
12
12
  "duckdb>=1.3.2",
13
+ "levenshtein>=0.27.1",
13
14
  "pandas>=2.3.1",
14
15
  "questionary>=2.1.0",
15
16
  "tqdm>=4.67.1",
@@ -5,6 +5,7 @@ from enum import Enum
5
5
  from typing import Optional, Union
6
6
 
7
7
  import click
8
+ from Levenshtein import ratio
8
9
  from unidecode import unidecode
9
10
 
10
11
  from tbr_deal_finder.config import Config
@@ -26,7 +27,11 @@ class Book:
26
27
  current_price: float
27
28
  timepoint: datetime
28
29
  format: Union[BookFormat, str]
30
+
31
+ # Metadata really only used for tracked books.
32
+ # See get_tbr_books for more context
29
33
  audiobook_isbn: str = None
34
+ audiobook_list_price: float = 0
30
35
 
31
36
  deleted: bool = False
32
37
 
@@ -35,16 +40,20 @@ class Book:
35
40
  normalized_authors: list[str] = None
36
41
 
37
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
+
38
51
  if not self.deal_id:
39
- self.deal_id = f"{self.title}__{self.normalized_authors}__{self.retailer}__{self.format}"
52
+ self.deal_id = f"{self.title}__{self.normalized_authors}__{self.format}__{self.retailer}"
40
53
 
41
54
  if isinstance(self.format, str):
42
55
  self.format = BookFormat(self.format)
43
56
 
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
57
  def discount(self) -> int:
49
58
  return int((self.list_price/self.current_price - 1) * 100)
50
59
 
@@ -56,6 +65,10 @@ class Book:
56
65
  def title_id(self) -> str:
57
66
  return f"{self.title}__{self.normalized_authors}__{self.format}"
58
67
 
68
+ @property
69
+ def full_title_str(self) -> str:
70
+ return f"{self.title}__{self.normalized_authors}"
71
+
59
72
  def list_price_string(self):
60
73
  return self.price_to_string(self.list_price)
61
74
 
@@ -74,6 +87,7 @@ class Book:
74
87
  response = dataclasses.asdict(self)
75
88
  response["format"] = self.format.value
76
89
  del response["audiobook_isbn"]
90
+ del response["audiobook_list_price"]
77
91
  del response["exists"]
78
92
  del response["normalized_authors"]
79
93
 
@@ -109,8 +123,33 @@ def print_books(books: list[Book]):
109
123
  click.echo(str(book))
110
124
 
111
125
 
126
+ def get_full_title_str(title: str, authors: Union[list, str]) -> str:
127
+ return f"{title}__{get_normalized_authors(authors)}"
128
+
129
+
130
+ def get_title_id(title: str, authors: Union[list, str], book_format: BookFormat) -> str:
131
+ return f"{title}__{get_normalized_authors(authors)}__{book_format.value}"
132
+
133
+
112
134
  def get_normalized_authors(authors: Union[str, list[str]]) -> list[str]:
113
135
  if isinstance(authors, str):
114
136
  authors = [i for i in authors.split(",")]
115
137
 
116
138
  return sorted([_AUTHOR_RE.sub('', unidecode(author)).lower() for author in authors])
139
+
140
+
141
+ def is_matching_authors(a1: list[str], a2: list[str]) -> bool:
142
+ """Checks if two normalized authors are matching.
143
+ Matching here means that they are at least 80% similar using levenshtein distance.
144
+
145
+ Score is calculated as follows:
146
+ 1 - (distance / (len1 + len2))
147
+
148
+ :param a1:
149
+ :param a2:
150
+ :return:
151
+ """
152
+ return any(
153
+ any(ratio(author1, author2, score_cutoff=.8) for author2 in a2)
154
+ for author1 in a1
155
+ )
@@ -9,7 +9,7 @@ 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_set_library_export_audiobook_isbn
12
+ from tbr_deal_finder.library_exports import maybe_enrich_library_exports
13
13
  from tbr_deal_finder.migrations import make_migrations
14
14
  from tbr_deal_finder.book import get_deals_found_at, print_books, get_active_deals
15
15
  from tbr_deal_finder.retailer import RETAILER_MAP
@@ -28,12 +28,6 @@ from tbr_deal_finder.utils import (
28
28
  def cli():
29
29
  make_migrations()
30
30
 
31
- # Check that the config exists for all commands ran
32
- try:
33
- Config.load()
34
- except FileNotFoundError:
35
- _set_config()
36
-
37
31
 
38
32
  def _add_path(existing_paths: list[str]) -> Union[str, None]:
39
33
  try:
@@ -68,24 +62,26 @@ def _set_library_export_paths(config: Config):
68
62
  Ensures that only valid, unique paths are added. Updates the config in-place.
69
63
  """
70
64
  while True:
71
- if config.library_export_paths:
72
- if len(config.library_export_paths) > 1:
73
- choices = ["Add new path", "Remove path", "Done"]
74
- else:
75
- choices = ["Add new path", "Done"]
76
-
77
- try:
78
- user_selection = questionary.select(
79
- "What change would you like to make to your library export paths",
80
- choices=choices,
81
- ).ask()
82
- except (KeyError, KeyboardInterrupt, TypeError):
83
- return
65
+ if len(config.library_export_paths) > 0:
66
+ choices = ["Add new path", "Remove path", "Done"]
84
67
  else:
85
- click.echo("Add your library export path.")
86
- user_selection = "Add new path"
68
+ choices = ["Add new path", "Done"]
69
+
70
+ try:
71
+ user_selection = questionary.select(
72
+ "What change would you like to make to your library export paths",
73
+ choices=choices,
74
+ ).ask()
75
+ except (KeyError, KeyboardInterrupt, TypeError):
76
+ return
87
77
 
88
78
  if user_selection == "Done":
79
+ if not config.library_export_paths:
80
+ if not click.confirm(
81
+ "Don't add a GoodReads or StoryGraph export and use wishlist entirely? "
82
+ "Note: Wishlist checks will still work even if you add your StoryGraph/GoodReads export."
83
+ ):
84
+ continue
89
85
  return
90
86
  elif user_selection == "Add new path":
91
87
  if new_path := _add_path(config.library_export_paths):
@@ -124,15 +120,20 @@ def _set_locale(config: Config):
124
120
 
125
121
 
126
122
  def _set_tracked_retailers(config: Config):
123
+ if not config.tracked_retailers:
124
+ echo_info(
125
+ "If you haven't heard of it, Chirp doesn't charge a subscription and has some great deals. \n"
126
+ "Note: I don't work for Chirp and this isn't a paid plug."
127
+ )
128
+
127
129
  while True:
128
130
  user_response = questionary.checkbox(
129
- "Select the retailers you want to check deals for.\n"
130
- "Tip: Chirp doesn't have a subscription and can have good deals. I'd recommend checking it.\n",
131
+ "Select the retailers you want to check deals for.\n",
131
132
  choices=[
132
133
  questionary.Choice(retailer, checked=retailer in config.tracked_retailers)
133
134
  for retailer in RETAILER_MAP.keys()
134
135
  ]).ask()
135
- if len(user_response) > 1:
136
+ if len(user_response) > 0:
136
137
  break
137
138
  else:
138
139
  echo_err("You must track deals for at least one retailer.")
@@ -183,9 +184,12 @@ def setup():
183
184
  @cli.command()
184
185
  def latest_deals():
185
186
  """Find book deals from your Library export."""
186
- config = Config.load()
187
+ try:
188
+ config = Config.load()
189
+ except FileNotFoundError:
190
+ config = _set_config()
187
191
 
188
- asyncio.run(maybe_set_library_export_audiobook_isbn(config))
192
+ asyncio.run(maybe_enrich_library_exports(config))
189
193
 
190
194
  db_conn = get_duckdb_conn()
191
195
  results = execute_query(
@@ -62,10 +62,16 @@ class Config:
62
62
  tracked_retailers_str = parser.get('DEFAULT', 'tracked_retailers')
63
63
  locale = parser.get('DEFAULT', 'locale', fallback="us")
64
64
  cls.set_locale(locale)
65
+
66
+ if export_paths_str:
67
+ library_export_paths = [i.strip() for i in export_paths_str.split(",")]
68
+ else:
69
+ library_export_paths = []
70
+
65
71
  return cls(
66
72
  max_price=parser.getfloat('DEFAULT', 'max_price', fallback=8.0),
67
73
  min_discount=parser.getint('DEFAULT', 'min_discount', fallback=35),
68
- library_export_paths=[i.strip() for i in export_paths_str.split(",")],
74
+ library_export_paths=library_export_paths,
69
75
  tracked_retailers=[i.strip() for i in tracked_retailers_str.split(",")]
70
76
  )
71
77
 
@@ -78,7 +84,9 @@ class Config:
78
84
  return ", ".join(self.tracked_retailers)
79
85
 
80
86
  def set_library_export_paths(self, library_export_paths: Union[str, list[str]]):
81
- if isinstance(library_export_paths, str):
87
+ if not library_export_paths:
88
+ self.library_export_paths = []
89
+ elif isinstance(library_export_paths, str):
82
90
  self.library_export_paths = [i.strip() for i in library_export_paths.split(",")]
83
91
  else:
84
92
  self.library_export_paths = library_export_paths
@@ -0,0 +1,208 @@
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)