tbr-deal-finder 0.1.6__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 (28) hide show
  1. {tbr_deal_finder-0.1.6 → tbr_deal_finder-0.1.7}/CHANGELOG.md +18 -0
  2. tbr_deal_finder-0.1.7/DESIGN.md +113 -0
  3. {tbr_deal_finder-0.1.6 → tbr_deal_finder-0.1.7}/PKG-INFO +7 -5
  4. {tbr_deal_finder-0.1.6 → tbr_deal_finder-0.1.7}/README.md +5 -4
  5. {tbr_deal_finder-0.1.6 → tbr_deal_finder-0.1.7}/pyproject.toml +2 -1
  6. {tbr_deal_finder-0.1.6 → tbr_deal_finder-0.1.7}/tbr_deal_finder/book.py +28 -2
  7. {tbr_deal_finder-0.1.6 → tbr_deal_finder-0.1.7}/tbr_deal_finder/cli.py +21 -22
  8. {tbr_deal_finder-0.1.6 → tbr_deal_finder-0.1.7}/tbr_deal_finder/config.py +10 -2
  9. {tbr_deal_finder-0.1.6 → tbr_deal_finder-0.1.7}/tbr_deal_finder/library_exports.py +6 -3
  10. tbr_deal_finder-0.1.7/tbr_deal_finder/owned_books.py +18 -0
  11. {tbr_deal_finder-0.1.6 → tbr_deal_finder-0.1.7}/tbr_deal_finder/retailer/audible.py +36 -0
  12. {tbr_deal_finder-0.1.6 → tbr_deal_finder-0.1.7}/tbr_deal_finder/retailer/chirp.py +61 -2
  13. {tbr_deal_finder-0.1.6 → tbr_deal_finder-0.1.7}/tbr_deal_finder/retailer/librofm.py +34 -3
  14. {tbr_deal_finder-0.1.6 → tbr_deal_finder-0.1.7}/tbr_deal_finder/retailer/models.py +2 -0
  15. {tbr_deal_finder-0.1.6 → tbr_deal_finder-0.1.7}/tbr_deal_finder/retailer_deal.py +43 -30
  16. {tbr_deal_finder-0.1.6 → tbr_deal_finder-0.1.7}/uv.lock +52 -1
  17. {tbr_deal_finder-0.1.6 → tbr_deal_finder-0.1.7}/.github/workflows/publish-to-pypi.yaml +0 -0
  18. {tbr_deal_finder-0.1.6 → tbr_deal_finder-0.1.7}/.gitignore +0 -0
  19. {tbr_deal_finder-0.1.6 → tbr_deal_finder-0.1.7}/.python-version +0 -0
  20. {tbr_deal_finder-0.1.6 → tbr_deal_finder-0.1.7}/LICENSE +0 -0
  21. {tbr_deal_finder-0.1.6 → tbr_deal_finder-0.1.7}/tbr_deal_finder/__init__.py +0 -0
  22. {tbr_deal_finder-0.1.6 → tbr_deal_finder-0.1.7}/tbr_deal_finder/migrations.py +0 -0
  23. {tbr_deal_finder-0.1.6 → tbr_deal_finder-0.1.7}/tbr_deal_finder/queries/get_active_deals.sql +0 -0
  24. {tbr_deal_finder-0.1.6 → tbr_deal_finder-0.1.7}/tbr_deal_finder/queries/get_deals_found_at.sql +0 -0
  25. {tbr_deal_finder-0.1.6 → tbr_deal_finder-0.1.7}/tbr_deal_finder/queries/latest_deal_last_ran_most_recent_success.sql +0 -0
  26. {tbr_deal_finder-0.1.6 → tbr_deal_finder-0.1.7}/tbr_deal_finder/retailer/__init__.py +0 -0
  27. {tbr_deal_finder-0.1.6 → tbr_deal_finder-0.1.7}/tbr_deal_finder/tracked_books.py +0 -0
  28. {tbr_deal_finder-0.1.6 → tbr_deal_finder-0.1.7}/tbr_deal_finder/utils.py +0 -0
@@ -1,6 +1,22 @@
1
1
 
2
2
  # Change Log
3
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
+
4
20
  ## 0.1.6 (July 30, 2025)
5
21
 
6
22
  Notes:
@@ -10,6 +26,8 @@ BUG FIXES:
10
26
  * Fixed issue where no deals would display if libro is the only tracked audiobook retailer.
11
27
  * Fixed retailer cli setup forcing a user to select at least two audiobook retailers.
12
28
 
29
+ ---
30
+
13
31
  ## 0.1.5 (July 30, 2025)
14
32
 
15
33
  Notes:
@@ -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.6
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,16 +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
+ - Use your StoryGraph exports, Goodreads exports, and custom csvs (spreadsheet) to track book deals
24
25
  - Supports multiple of the library exports above
25
26
  - Tracks deals on the wishlist of all your configured retailers like audible
26
27
  - Supports multiple locales and currencies
27
- - Finds the latest and active deals from supported sellers
28
+ - Find the latest and active deals from supported sellers
28
29
  - Simple CLI interface for setup and usage
29
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
30
32
 
31
33
  ## Support
32
34
 
@@ -67,7 +69,7 @@ Track price drops and find deals on books in your TBR (To Be Read) list across a
67
69
  https://docs.astral.sh/uv/getting-started/installation/
68
70
 
69
71
  ## Configuration
70
- 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.
71
73
  Here are the steps to get your export.
72
74
 
73
75
  ### StoryGraph
@@ -1,15 +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
6
+ - Use your StoryGraph exports, Goodreads exports, and custom csvs (spreadsheet) to track book deals
7
7
  - Supports multiple of the library exports above
8
8
  - Tracks deals on the wishlist of all your configured retailers like audible
9
9
  - Supports multiple locales and currencies
10
- - Finds the latest and active deals from supported sellers
10
+ - Find the latest and active deals from supported sellers
11
11
  - Simple CLI interface for setup and usage
12
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
13
14
 
14
15
  ## Support
15
16
 
@@ -50,7 +51,7 @@ Track price drops and find deals on books in your TBR (To Be Read) list across a
50
51
  https://docs.astral.sh/uv/getting-started/installation/
51
52
 
52
53
  ## Configuration
53
- 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.
54
55
  Here are the steps to get your export.
55
56
 
56
57
  ### StoryGraph
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "tbr-deal-finder"
3
- version = "0.1.6"
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
@@ -43,6 +44,10 @@ class Book:
43
44
  self.list_price = round(self.list_price, 2)
44
45
  self.normalized_authors = get_normalized_authors(self.authors)
45
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
+
46
51
  if not self.deal_id:
47
52
  self.deal_id = f"{self.title}__{self.normalized_authors}__{self.format}__{self.retailer}"
48
53
 
@@ -118,6 +123,14 @@ def print_books(books: list[Book]):
118
123
  click.echo(str(book))
119
124
 
120
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
+
121
134
  def get_normalized_authors(authors: Union[str, list[str]]) -> list[str]:
122
135
  if isinstance(authors, str):
123
136
  authors = [i for i in authors.split(",")]
@@ -125,5 +138,18 @@ def get_normalized_authors(authors: Union[str, list[str]]) -> list[str]:
125
138
  return sorted([_AUTHOR_RE.sub('', unidecode(author)).lower() for author in authors])
126
139
 
127
140
 
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}"
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
+ )
@@ -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):
@@ -188,7 +184,10 @@ def setup():
188
184
  @cli.command()
189
185
  def latest_deals():
190
186
  """Find book deals from your Library export."""
191
- config = Config.load()
187
+ try:
188
+ config = Config.load()
189
+ except FileNotFoundError:
190
+ config = _set_config()
192
191
 
193
192
  asyncio.run(maybe_enrich_library_exports(config))
194
193
 
@@ -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
@@ -7,7 +7,7 @@ from typing import Callable, Awaitable, Optional
7
7
 
8
8
  from tqdm.asyncio import tqdm_asyncio
9
9
 
10
- from tbr_deal_finder.book import Book, BookFormat, get_normalized_authors
10
+ from tbr_deal_finder.book import Book, BookFormat, get_full_title_str
11
11
  from tbr_deal_finder.config import Config
12
12
  from tbr_deal_finder.retailer import LibroFM, Chirp
13
13
 
@@ -61,6 +61,9 @@ async def _maybe_set_column_for_library_exports(
61
61
  :param column_name:
62
62
  :return:
63
63
  """
64
+ if not config.library_export_paths:
65
+ return
66
+
64
67
  if not column_name:
65
68
  column_name = attr_name
66
69
 
@@ -77,7 +80,7 @@ async def _maybe_set_column_for_library_exports(
77
80
 
78
81
  title = get_book_title(book_dict)
79
82
  authors = get_book_authors(book_dict)
80
- key = f'{title}__{get_normalized_authors(authors)}'
83
+ key = get_full_title_str(title, authors)
81
84
 
82
85
  if column_name in book_dict:
83
86
  # Keep state of value for this book/key
@@ -136,7 +139,7 @@ async def _maybe_set_column_for_library_exports(
136
139
  if is_tbr_book(book_dict):
137
140
  title = get_book_title(book_dict)
138
141
  authors = get_book_authors(book_dict)
139
- key = f'{title}__{get_normalized_authors(authors)}'
142
+ key = get_full_title_str(title, authors)
140
143
 
141
144
  if key in book_to_col_val_map:
142
145
  col_val = book_to_col_val_map[key]
@@ -0,0 +1,18 @@
1
+ from tbr_deal_finder.book import Book
2
+ from tbr_deal_finder.config import Config
3
+ from tbr_deal_finder.retailer import RETAILER_MAP
4
+ from tbr_deal_finder.retailer.models import Retailer
5
+
6
+
7
+ async def get_owned_books(config: Config) -> list[Book]:
8
+ owned_books = []
9
+
10
+ for retailer_str in config.tracked_retailers:
11
+ retailer: Retailer = RETAILER_MAP[retailer_str]()
12
+ await retailer.set_auth()
13
+
14
+ owned_books.extend(
15
+ await retailer.get_library(config)
16
+ )
17
+
18
+ return owned_books
@@ -171,3 +171,39 @@ class Audible(Retailer):
171
171
  total_pages = math.ceil(int(response.get("total_results", 1))/page_size)
172
172
 
173
173
  return wishlist_books
174
+
175
+ async def get_library(self, config: Config) -> list[Book]:
176
+ library_books = []
177
+
178
+ page = 1
179
+ total_pages = 1
180
+ page_size = 1000
181
+ while page <= total_pages:
182
+ response = await self._client.get(
183
+ "1.0/library",
184
+ num_results=page_size,
185
+ page=page,
186
+ response_groups=[
187
+ "contributors, product_attrs, product_desc, product_extended_attrs"
188
+ ]
189
+ )
190
+
191
+ for audiobook in response.get("items", []):
192
+ authors = [author["name"] for author in audiobook["authors"]]
193
+ library_books.append(
194
+ Book(
195
+ retailer=self.name,
196
+ title=audiobook["title"],
197
+ authors=", ".join(authors),
198
+ list_price=1,
199
+ current_price=1,
200
+ timepoint=config.run_time,
201
+ format=self.format,
202
+ audiobook_isbn=audiobook["isbn"],
203
+ )
204
+ )
205
+
206
+ page += 1
207
+ total_pages = math.ceil(int(response.get("total_results", 1))/page_size)
208
+
209
+ return library_books
@@ -2,6 +2,7 @@ import asyncio
2
2
  import json
3
3
  import os
4
4
  from datetime import datetime, timedelta
5
+ from textwrap import dedent
5
6
 
6
7
  import aiohttp
7
8
  import click
@@ -9,7 +10,7 @@ import click
9
10
  from tbr_deal_finder import TBR_DEALS_PATH
10
11
  from tbr_deal_finder.config import Config
11
12
  from tbr_deal_finder.retailer.models import Retailer
12
- from tbr_deal_finder.book import Book, BookFormat, get_normalized_authors
13
+ from tbr_deal_finder.book import Book, BookFormat, get_normalized_authors, is_matching_authors
13
14
  from tbr_deal_finder.utils import currency_to_float, echo_err
14
15
 
15
16
 
@@ -120,7 +121,7 @@ class Chirp(Retailer):
120
121
  normalized_authors = get_normalized_authors([author["name"] for author in book["allAuthors"]])
121
122
  if (
122
123
  book["displayTitle"] == title
123
- and any(author in normalized_authors for author in target.normalized_authors)
124
+ and is_matching_authors(target.normalized_authors, normalized_authors)
124
125
  ):
125
126
  return Book(
126
127
  retailer=self.name,
@@ -180,3 +181,61 @@ class Chirp(Retailer):
180
181
  )
181
182
 
182
183
  page += 1
184
+
185
+ async def get_library(self, config: Config) -> list[Book]:
186
+ library_books = []
187
+ page = 1
188
+ query = dedent("""
189
+ query AndroidCurrentUserAudiobooks($page: Int!, $pageSize: Int!) {
190
+ currentUserAudiobooks(page: $page, pageSize: $pageSize, sort: TITLE_A_Z, clientCapabilities: [CHIRP_AUDIO]) {
191
+ audiobook {
192
+ id
193
+ allAuthors{name}
194
+ displayTitle
195
+ displayAuthors
196
+ displayNarrators
197
+ durationMs
198
+ description
199
+ publisher
200
+ }
201
+ archived
202
+ playable
203
+ finishedAt
204
+ currentOverallOffsetMs
205
+ }
206
+ }
207
+ """)
208
+
209
+ while True:
210
+ response = await self.make_request(
211
+ "POST",
212
+ json={
213
+ "query": query,
214
+ "variables": {"page": page, "pageSize": 15},
215
+ "operationName": "AndroidCurrentUserAudiobooks"
216
+ }
217
+ )
218
+
219
+ audiobooks = response.get(
220
+ "data", {}
221
+ ).get("currentUserAudiobooks", [])
222
+
223
+ if not audiobooks:
224
+ return library_books
225
+
226
+ for book in audiobooks:
227
+ audiobook = book["audiobook"]
228
+ authors = [author["name"] for author in audiobook["allAuthors"]]
229
+ library_books.append(
230
+ Book(
231
+ retailer=self.name,
232
+ title=audiobook["displayTitle"],
233
+ authors=", ".join(authors),
234
+ list_price=1,
235
+ current_price=1,
236
+ timepoint=config.run_time,
237
+ format=self.format,
238
+ )
239
+ )
240
+
241
+ page += 1
@@ -10,7 +10,7 @@ import click
10
10
  from tbr_deal_finder import TBR_DEALS_PATH
11
11
  from tbr_deal_finder.config import Config
12
12
  from tbr_deal_finder.retailer.models import Retailer
13
- from tbr_deal_finder.book import Book, BookFormat, get_normalized_authors
13
+ from tbr_deal_finder.book import Book, BookFormat, get_normalized_authors, is_matching_authors
14
14
  from tbr_deal_finder.utils import currency_to_float
15
15
 
16
16
 
@@ -101,7 +101,7 @@ class LibroFM(Retailer):
101
101
 
102
102
  if (
103
103
  title == b["title"]
104
- and any(author in normalized_authors for author in book.normalized_authors)
104
+ and is_matching_authors(book.normalized_authors, normalized_authors)
105
105
  ):
106
106
  book.audiobook_isbn = b["isbn"]
107
107
  break
@@ -165,7 +165,7 @@ class LibroFM(Retailer):
165
165
  response = await self.make_request(
166
166
  f"api/v10/explore/wishlist",
167
167
  "GET",
168
- params=dict(page=2)
168
+ params=dict(page=page)
169
169
  )
170
170
  wishlist = response.get("data", {}).get("wishlist", {})
171
171
  if not wishlist:
@@ -189,3 +189,34 @@ class LibroFM(Retailer):
189
189
  total_pages = wishlist["total_pages"]
190
190
 
191
191
  return wishlist_books
192
+
193
+ async def get_library(self, config: Config) -> list[Book]:
194
+ library_books = []
195
+
196
+ page = 1
197
+ total_pages = 1
198
+ while page <= total_pages:
199
+ response = await self.make_request(
200
+ f"api/v10/library",
201
+ "GET",
202
+ params=dict(page=page)
203
+ )
204
+
205
+ for book in response.get("audiobooks", []):
206
+ library_books.append(
207
+ Book(
208
+ retailer=self.name,
209
+ title=book["title"],
210
+ authors=", ".join(book["authors"]),
211
+ list_price=1,
212
+ current_price=1,
213
+ timepoint=config.run_time,
214
+ format=self.format,
215
+ audiobook_isbn=book["isbn"],
216
+ )
217
+ )
218
+
219
+ page += 1
220
+ total_pages = response["total_pages"]
221
+
222
+ return library_books
@@ -50,4 +50,6 @@ class Retailer(abc.ABC):
50
50
  async def get_wishlist(self, config: Config) -> list[Book]:
51
51
  raise NotImplementedError
52
52
 
53
+ async def get_library(self, config: Config) -> list[Book]:
54
+ raise NotImplementedError
53
55
 
@@ -8,6 +8,7 @@ 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
11
12
  from tbr_deal_finder.tracked_books import get_tbr_books
12
13
  from tbr_deal_finder.retailer import RETAILER_MAP
13
14
  from tbr_deal_finder.retailer.models import Retailer
@@ -41,7 +42,7 @@ def update_retailer_deal_table(config: Config, new_deals: list[Book]):
41
42
  # Any remaining values in active_deal_map mean that
42
43
  # it wasn't found and should be marked for deletion
43
44
  for deal in active_deal_map.values():
44
- echo_warning(f"{str(deal)} is no longer active")
45
+ echo_warning(f"{str(deal)} is no longer active\n")
45
46
  deal.timepoint = config.run_time
46
47
  deal.deleted = True
47
48
  df_data.append(deal.dict())
@@ -55,21 +56,6 @@ def update_retailer_deal_table(config: Config, new_deals: list[Book]):
55
56
  db_conn.unregister("_df")
56
57
 
57
58
 
58
- def _retry_books(found_books: list[Book], all_books: list[Book]) -> list[Book]:
59
- response = []
60
- found_book_set = {f'{b.title} - {b.authors}' for b in found_books}
61
- for book in all_books:
62
- if ":" not in book.title:
63
- continue
64
-
65
- if f'{book.title} - {book.authors}' not in found_book_set:
66
- alt_book = copy.deepcopy(book)
67
- alt_book.title = alt_book.title.split(":")[0]
68
- response.append(alt_book)
69
-
70
- return response
71
-
72
-
73
59
  async def _get_books(config, retailer: Retailer, books: list[Book]) -> list[Book]:
74
60
  """Get Books with limited concurrency.
75
61
 
@@ -100,13 +86,9 @@ async def _get_books(config, retailer: Retailer, books: list[Book]) -> list[Book
100
86
  elif not book.exists:
101
87
  unresolved_books.append(book)
102
88
 
103
- if retry_books := _retry_books(response, books):
104
- echo_info("Attempting to find missing books with alternate title")
105
- response.extend(await _get_books(config, retailer, retry_books))
106
- elif unresolved_books:
107
- click.echo()
108
- for book in unresolved_books:
109
- echo_info(f"{book.title} by {book.authors} not found")
89
+ click.echo()
90
+ for book in unresolved_books:
91
+ echo_info(f"{book.title} by {book.authors} not found")
110
92
 
111
93
  return response
112
94
 
@@ -145,6 +127,34 @@ def _apply_proper_list_prices(books: list[Book]):
145
127
  book.list_price = max(book.current_price, list_price)
146
128
 
147
129
 
130
+ def _get_retailer_relevant_tbr_books(
131
+ retailer: Retailer,
132
+ books: list[Book],
133
+ owned_book_title_map: dict[str, dict[BookFormat, Book]],
134
+ ) -> list[Book]:
135
+ """
136
+ 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
+ :param retailer:
140
+ :param books:
141
+ :param owned_book_title_map:
142
+ :return:
143
+ """
144
+
145
+ response = []
146
+
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
+ ):
153
+ response.append(book)
154
+
155
+ return response
156
+
157
+
148
158
  async def get_latest_deals(config: Config):
149
159
  """
150
160
  Fetches the latest book deals from all tracked retailers for the user's TBR list.
@@ -164,18 +174,21 @@ async def get_latest_deals(config: Config):
164
174
 
165
175
  books: list[Book] = []
166
176
  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
167
182
 
168
183
  for retailer_str in config.tracked_retailers:
169
184
  retailer = RETAILER_MAP[retailer_str]()
170
185
  await retailer.set_auth()
171
186
 
172
- # Don't check on deals in a specified format
173
- # that does not match the format the retailer sells
174
- relevant_tbr_books = [
175
- book
176
- for book in tbr_books
177
- if book.format == BookFormat.NA or book.format == retailer.format
178
- ]
187
+ relevant_tbr_books = _get_retailer_relevant_tbr_books(
188
+ retailer,
189
+ tbr_books,
190
+ owned_book_title_map,
191
+ )
179
192
 
180
193
  echo_info(f"Getting deals from {retailer.name}")
181
194
  click.echo("\n---------------")
@@ -244,6 +244,32 @@ wheels = [
244
244
  { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442 },
245
245
  ]
246
246
 
247
+ [[package]]
248
+ name = "levenshtein"
249
+ version = "0.27.1"
250
+ source = { registry = "https://pypi.org/simple" }
251
+ dependencies = [
252
+ { name = "rapidfuzz" },
253
+ ]
254
+ sdist = { url = "https://files.pythonhosted.org/packages/7e/b3/b5f8011483ba9083a0bc74c4d58705e9cf465fbe55c948a1b1357d0a2aa8/levenshtein-0.27.1.tar.gz", hash = "sha256:3e18b73564cfc846eec94dd13fab6cb006b5d2e0cc56bad1fd7d5585881302e3", size = 382571 }
255
+ wheels = [
256
+ { url = "https://files.pythonhosted.org/packages/c6/d3/30485fb9aee848542ee2d01aba85106a7f5da982ebeeffc619f70ea593c7/levenshtein-0.27.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ab00c2cae2889166afb7e1af64af2d4e8c1b126f3902d13ef3740df00e54032d", size = 173397 },
257
+ { url = "https://files.pythonhosted.org/packages/df/9f/40a81c54cfe74b22737710e654bd25ad934a675f737b60b24f84099540e0/levenshtein-0.27.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c27e00bc7527e282f7c437817081df8da4eb7054e7ef9055b851fa3947896560", size = 155787 },
258
+ { url = "https://files.pythonhosted.org/packages/df/98/915f4e24e21982b6eca2c0203546c160f4a83853fa6a2ac6e2b208a54afc/levenshtein-0.27.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a5b07de42bfc051136cc8e7f1e7ba2cb73666aa0429930f4218efabfdc5837ad", size = 150013 },
259
+ { url = "https://files.pythonhosted.org/packages/80/93/9b0773107580416b9de14bf6a12bd1dd2b2964f7a9f6fb0e40723e1f0572/levenshtein-0.27.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fb11ad3c9dae3063405aa50d9c96923722ab17bb606c776b6817d70b51fd7e07", size = 181234 },
260
+ { url = "https://files.pythonhosted.org/packages/91/b1/3cd4f69af32d40de14808142cc743af3a1b737b25571bd5e8d2f46b885e0/levenshtein-0.27.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5c5986fb46cb0c063305fd45b0a79924abf2959a6d984bbac2b511d3ab259f3f", size = 183697 },
261
+ { url = "https://files.pythonhosted.org/packages/bb/65/b691e502c6463f6965b7e0d8d84224c188aa35b53fbc85853c72a0e436c9/levenshtein-0.27.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:75191e469269ddef2859bc64c4a8cfd6c9e063302766b5cb7e1e67f38cc7051a", size = 159964 },
262
+ { url = "https://files.pythonhosted.org/packages/0f/c0/89a922a47306a475fb6d8f2ab08668f143d3dc7dea4c39d09e46746e031c/levenshtein-0.27.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:51b3a7b2266933babc04e4d9821a495142eebd6ef709f90e24bc532b52b81385", size = 244759 },
263
+ { url = "https://files.pythonhosted.org/packages/b4/93/30283c6e69a6556b02e0507c88535df9613179f7b44bc49cdb4bc5e889a3/levenshtein-0.27.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bbac509794afc3e2a9e73284c9e3d0aab5b1d928643f42b172969c3eefa1f2a3", size = 1115955 },
264
+ { url = "https://files.pythonhosted.org/packages/0b/cf/7e19ea2c23671db02fbbe5a5a4aeafd1d471ee573a6251ae17008458c434/levenshtein-0.27.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:8d68714785178347ecb272b94e85cbf7e638165895c4dd17ab57e7742d8872ec", size = 1400921 },
265
+ { url = "https://files.pythonhosted.org/packages/e3/f7/fb42bfe2f3b46ef91f0fc6fa217b44dbeb4ef8c72a9c1917bbbe1cafc0f8/levenshtein-0.27.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:8ee74ee31a5ab8f61cd6c6c6e9ade4488dde1285f3c12207afc018393c9b8d14", size = 1225037 },
266
+ { url = "https://files.pythonhosted.org/packages/74/25/c86f8874ac7b0632b172d0d1622ed3ab9608a7f8fe85d41d632b16f5948e/levenshtein-0.27.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:f2441b6365453ec89640b85344afd3d602b0d9972840b693508074c613486ce7", size = 1420601 },
267
+ { url = "https://files.pythonhosted.org/packages/20/fe/ebfbaadcd90ea7dfde987ae95b5c11dc27c2c5d55a2c4ccbbe4e18a8af7b/levenshtein-0.27.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a9be39640a46d8a0f9be729e641651d16a62b2c07d3f4468c36e1cc66b0183b9", size = 1188241 },
268
+ { url = "https://files.pythonhosted.org/packages/2e/1a/aa6b07316e10781a6c5a5a8308f9bdc22213dc3911b959daa6d7ff654fc6/levenshtein-0.27.1-cp313-cp313-win32.whl", hash = "sha256:a520af67d976761eb6580e7c026a07eb8f74f910f17ce60e98d6e492a1f126c7", size = 88103 },
269
+ { url = "https://files.pythonhosted.org/packages/9d/7b/9bbfd417f80f1047a28d0ea56a9b38b9853ba913b84dd5998785c5f98541/levenshtein-0.27.1-cp313-cp313-win_amd64.whl", hash = "sha256:7dd60aa49c2d8d23e0ef6452c8329029f5d092f386a177e3385d315cabb78f2a", size = 100579 },
270
+ { url = "https://files.pythonhosted.org/packages/8b/01/5f3ff775db7340aa378b250e2a31e6b4b038809a24ff0a3636ef20c7ca31/levenshtein-0.27.1-cp313-cp313-win_arm64.whl", hash = "sha256:149cd4f0baf5884ac5df625b7b0d281721b15de00f447080e38f5188106e1167", size = 87933 },
271
+ ]
272
+
247
273
  [[package]]
248
274
  name = "multidict"
249
275
  version = "6.6.3"
@@ -508,6 +534,29 @@ wheels = [
508
534
  { url = "https://files.pythonhosted.org/packages/ad/3f/11dd4cd4f39e05128bfd20138faea57bec56f9ffba6185d276e3107ba5b2/questionary-2.1.0-py3-none-any.whl", hash = "sha256:44174d237b68bc828e4878c763a9ad6790ee61990e0ae72927694ead57bab8ec", size = 36747 },
509
535
  ]
510
536
 
537
+ [[package]]
538
+ name = "rapidfuzz"
539
+ version = "3.13.0"
540
+ source = { registry = "https://pypi.org/simple" }
541
+ sdist = { url = "https://files.pythonhosted.org/packages/ed/f6/6895abc3a3d056b9698da3199b04c0e56226d530ae44a470edabf8b664f0/rapidfuzz-3.13.0.tar.gz", hash = "sha256:d2eaf3839e52cbcc0accbe9817a67b4b0fcf70aaeb229cfddc1c28061f9ce5d8", size = 57904226 }
542
+ wheels = [
543
+ { url = "https://files.pythonhosted.org/packages/0a/76/606e71e4227790750f1646f3c5c873e18d6cfeb6f9a77b2b8c4dec8f0f66/rapidfuzz-3.13.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:09e908064d3684c541d312bd4c7b05acb99a2c764f6231bd507d4b4b65226c23", size = 1982282 },
544
+ { url = "https://files.pythonhosted.org/packages/0a/f5/d0b48c6b902607a59fd5932a54e3518dae8223814db8349b0176e6e9444b/rapidfuzz-3.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:57c390336cb50d5d3bfb0cfe1467478a15733703af61f6dffb14b1cd312a6fae", size = 1439274 },
545
+ { url = "https://files.pythonhosted.org/packages/59/cf/c3ac8c80d8ced6c1f99b5d9674d397ce5d0e9d0939d788d67c010e19c65f/rapidfuzz-3.13.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0da54aa8547b3c2c188db3d1c7eb4d1bb6dd80baa8cdaeaec3d1da3346ec9caa", size = 1399854 },
546
+ { url = "https://files.pythonhosted.org/packages/09/5d/ca8698e452b349c8313faf07bfa84e7d1c2d2edf7ccc67bcfc49bee1259a/rapidfuzz-3.13.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:df8e8c21e67afb9d7fbe18f42c6111fe155e801ab103c81109a61312927cc611", size = 5308962 },
547
+ { url = "https://files.pythonhosted.org/packages/66/0a/bebada332854e78e68f3d6c05226b23faca79d71362509dbcf7b002e33b7/rapidfuzz-3.13.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:461fd13250a2adf8e90ca9a0e1e166515cbcaa5e9c3b1f37545cbbeff9e77f6b", size = 1625016 },
548
+ { url = "https://files.pythonhosted.org/packages/de/0c/9e58d4887b86d7121d1c519f7050d1be5eb189d8a8075f5417df6492b4f5/rapidfuzz-3.13.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c2b3dd5d206a12deca16870acc0d6e5036abeb70e3cad6549c294eff15591527", size = 1600414 },
549
+ { url = "https://files.pythonhosted.org/packages/9b/df/6096bc669c1311568840bdcbb5a893edc972d1c8d2b4b4325c21d54da5b1/rapidfuzz-3.13.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1343d745fbf4688e412d8f398c6e6d6f269db99a54456873f232ba2e7aeb4939", size = 3053179 },
550
+ { url = "https://files.pythonhosted.org/packages/f9/46/5179c583b75fce3e65a5cd79a3561bd19abd54518cb7c483a89b284bf2b9/rapidfuzz-3.13.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b1b065f370d54551dcc785c6f9eeb5bd517ae14c983d2784c064b3aa525896df", size = 2456856 },
551
+ { url = "https://files.pythonhosted.org/packages/6b/64/e9804212e3286d027ac35bbb66603c9456c2bce23f823b67d2f5cabc05c1/rapidfuzz-3.13.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:11b125d8edd67e767b2295eac6eb9afe0b1cdc82ea3d4b9257da4b8e06077798", size = 7567107 },
552
+ { url = "https://files.pythonhosted.org/packages/8a/f2/7d69e7bf4daec62769b11757ffc31f69afb3ce248947aadbb109fefd9f65/rapidfuzz-3.13.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c33f9c841630b2bb7e69a3fb5c84a854075bb812c47620978bddc591f764da3d", size = 2854192 },
553
+ { url = "https://files.pythonhosted.org/packages/05/21/ab4ad7d7d0f653e6fe2e4ccf11d0245092bef94cdff587a21e534e57bda8/rapidfuzz-3.13.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:ae4574cb66cf1e85d32bb7e9ec45af5409c5b3970b7ceb8dea90168024127566", size = 3398876 },
554
+ { url = "https://files.pythonhosted.org/packages/0f/a8/45bba94c2489cb1ee0130dcb46e1df4fa2c2b25269e21ffd15240a80322b/rapidfuzz-3.13.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e05752418b24bbd411841b256344c26f57da1148c5509e34ea39c7eb5099ab72", size = 4377077 },
555
+ { url = "https://files.pythonhosted.org/packages/0c/f3/5e0c6ae452cbb74e5436d3445467447e8c32f3021f48f93f15934b8cffc2/rapidfuzz-3.13.0-cp313-cp313-win32.whl", hash = "sha256:0e1d08cb884805a543f2de1f6744069495ef527e279e05370dd7c83416af83f8", size = 1822066 },
556
+ { url = "https://files.pythonhosted.org/packages/96/e3/a98c25c4f74051df4dcf2f393176b8663bfd93c7afc6692c84e96de147a2/rapidfuzz-3.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:9a7c6232be5f809cd39da30ee5d24e6cadd919831e6020ec6c2391f4c3bc9264", size = 1615100 },
557
+ { url = "https://files.pythonhosted.org/packages/60/b1/05cd5e697c00cd46d7791915f571b38c8531f714832eff2c5e34537c49ee/rapidfuzz-3.13.0-cp313-cp313-win_arm64.whl", hash = "sha256:3f32f15bacd1838c929b35c84b43618481e1b3d7a61b5ed2db0291b70ae88b53", size = 858976 },
558
+ ]
559
+
511
560
  [[package]]
512
561
  name = "rfc3986"
513
562
  version = "1.5.0"
@@ -563,13 +612,14 @@ wheels = [
563
612
 
564
613
  [[package]]
565
614
  name = "tbr-deal-finder"
566
- version = "0.1.6"
615
+ version = "0.1.7"
567
616
  source = { editable = "." }
568
617
  dependencies = [
569
618
  { name = "aiohttp" },
570
619
  { name = "audible" },
571
620
  { name = "click" },
572
621
  { name = "duckdb" },
622
+ { name = "levenshtein" },
573
623
  { name = "pandas" },
574
624
  { name = "questionary" },
575
625
  { name = "tqdm" },
@@ -582,6 +632,7 @@ requires-dist = [
582
632
  { name = "audible", specifier = "==0.8.2" },
583
633
  { name = "click", specifier = ">=8.2.1" },
584
634
  { name = "duckdb", specifier = ">=1.3.2" },
635
+ { name = "levenshtein", specifier = ">=0.27.1" },
585
636
  { name = "pandas", specifier = ">=2.3.1" },
586
637
  { name = "questionary", specifier = ">=2.1.0" },
587
638
  { name = "tqdm", specifier = ">=4.67.1" },
File without changes