Skip to content

Commit 360b214

Browse files
author
Philip Johansson
committed
switch from list to set for deduplication
1 parent 23910ee commit 360b214

19 files changed

Lines changed: 154 additions & 70 deletions

src/murid/clients/calibre.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ def validate(self) -> None:
5656
logger.error("Error connecting to Calibre database: %s", e)
5757
raise CalibreError(f"Error connecting to Calibre database: {e}") from e
5858

59-
def get_books(self) -> list[Book]:
59+
def get_books(self) -> set[Book]:
6060
"""Retrieve a list of books from the Calibre database."""
6161
try:
6262
conn = self.connect(f"file:{self.db_path}?mode=ro", uri=True)
@@ -90,7 +90,7 @@ def get_books(self) -> list[Book]:
9090

9191
rows = cursor.fetchall()
9292
conn.close()
93-
books = [
93+
books = {
9494
Book(
9595
id=row["id"],
9696
title=row["title"],
@@ -100,7 +100,7 @@ def get_books(self) -> list[Book]:
100100
source="calibre",
101101
)
102102
for row in rows
103-
]
103+
}
104104
return books
105105

106106
def add_book(self, book: Book, path: str) -> None:

src/murid/clients/hardcover.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -150,15 +150,15 @@ def _extract_isbn(editions: list[dict] | None) -> list[str | None]:
150150

151151
return isbns
152152

153-
def get_books(self) -> list[Book]:
153+
def get_books(self) -> set[Book]:
154154
"""Fetch the user's "Want to Read" books from the Hardcover API.
155155
156156
Return them as a list of Book objects.
157157
"""
158158
data = self.fetch_data()
159159
items = data.get("data", {}).get("user_books", [])
160160

161-
books: list[Book] = []
161+
books: set[Book] = set()
162162

163163
for item in items:
164164
book = item.get("book", {})
@@ -171,7 +171,7 @@ def get_books(self) -> list[Book]:
171171
isbn = self._extract_isbn(editions)
172172
series, series_number = self._extract_series_info(book)
173173

174-
books.append(
174+
books.add(
175175
Book(
176176
id=book.get("id"),
177177
title=book.get("title"),

src/murid/clients/myanonamouse.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ def _request(self, method: str, url: str, **kwargs) -> requests.Response:
5555
self._last_request_time = time.monotonic()
5656
return self.session.request(method, url, **kwargs)
5757

58-
def search(self, query: MyAnonamouseQuery) -> list[Torrent]:
58+
def search(self, query: MyAnonamouseQuery) -> set[Torrent]:
5959
"""Search for torrents on MyAnonamouse matching the specified criteria."""
6060

6161
payload = {
@@ -89,17 +89,17 @@ def search(self, query: MyAnonamouseQuery) -> list[Torrent]:
8989
response.raise_for_status()
9090
except requests.RequestException as e:
9191
logger.error("Error searching MyAnonamouse: %s", e)
92-
return []
92+
return set()
9393

9494
data = response.json()
9595

9696
if "error" in data:
97-
return []
97+
return set()
9898

9999
if "data" not in data:
100100
raise MAMError(f"Unexpected response: {data}")
101101

102-
return [self._parse_torrent(row) for row in data["data"][:100]]
102+
return {self._parse_torrent(row) for row in data["data"][:100]}
103103

104104
@staticmethod
105105
def _parse_torrent(data: dict[str, Any]) -> Torrent:
@@ -135,7 +135,7 @@ def _parse_torrent(data: dict[str, Any]) -> Torrent:
135135
raw=data,
136136
)
137137

138-
def search_ebook(self, title: str, author: str | None = None) -> list[Torrent]:
138+
def search_ebook(self, title: str, author: str | None = None) -> set[Torrent]:
139139
"""Search for ebooks on MyAnonamouse matching the specified title and optional author."""
140140
query = title
141141
if author:

src/murid/domain/book.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,3 +17,30 @@ class Book:
1717

1818
def __str__(self):
1919
return f"{self.title} by {', '.join(self.authors)}"
20+
21+
def __hash__(self):
22+
return hash(
23+
(
24+
self.title,
25+
tuple(self.authors),
26+
self.id,
27+
self.source,
28+
self.series,
29+
self.series_number,
30+
tuple(self.isbn),
31+
)
32+
)
33+
34+
def __eq__(self, other):
35+
if not isinstance(other, Book):
36+
return NotImplemented
37+
38+
return (
39+
self.title == other.title
40+
and self.authors == other.authors
41+
and self.id == other.id
42+
and self.isbn == other.isbn
43+
and self.source == other.source
44+
and self.series == other.series
45+
and self.series_number == other.series_number
46+
)

src/murid/domain/book_matcher.py

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import logging
44
import re
5+
from typing import Iterable
56

67
from rapidfuzz import fuzz
78

@@ -105,17 +106,17 @@ def best_match(self, book: Book, candidates: list[Book]) -> tuple[Book | None, f
105106

106107
def match_books(
107108
self,
108-
books_a: list[Book],
109-
books_b: list[Book],
110-
) -> list[tuple[Book, Book, float]]:
109+
books_a: Iterable[Book],
110+
books_b: Iterable[Book],
111+
) -> set[tuple[Book, Book, float]]:
111112
"""Match books from two lists and return a list of matched pairs and their similarity."""
112113

113-
matches = []
114+
matches = set()
114115

115116
for book_b in books_b:
116117
match, score = self.best_match(book_b, books_a)
117118
if match and score >= self.threshold:
118-
matches.append((match, book_b, score))
119+
matches.add((match, book_b, score))
119120
else:
120121
logger.debug("No match for %s in calibre db. Best similarity: %.2f", book_b, score)
121122

src/murid/domain/torrent.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,3 +41,28 @@ def download_url(self) -> str | None:
4141
def __str__(self) -> str:
4242
"""Return a string representation of the torrent as a link to the torrents page."""
4343
return f"https://www.myanonamouse.net/t/{self.book.id}"
44+
45+
def __hash__(self) -> int:
46+
if self.download_hash is not None:
47+
return hash(self.download_hash)
48+
49+
return hash(
50+
(
51+
self.book,
52+
self.language,
53+
tuple(self.file_types or ()),
54+
)
55+
)
56+
57+
def __eq__(self, other) -> bool:
58+
if not isinstance(other, Torrent):
59+
return NotImplemented
60+
61+
if self.download_hash is not None and other.download_hash is not None:
62+
return self.download_hash == other.download_hash
63+
64+
return (
65+
self.book == other.book
66+
and self.language == other.language
67+
and self.file_types == other.file_types
68+
)

src/murid/domain/torrent_selector.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""Class for selecting the best matching torrent for a given book"""
22

33
import logging
4+
from typing import Iterable
45

56
from .book import Book
67
from .book_matcher import BookMatcher
@@ -23,14 +24,16 @@ def __init__(
2324
wanted_filetypes = {"epub", "mobi", "azw3"}
2425
self.wanted_filetypes = wanted_filetypes
2526

26-
def select(self, book: Book, torrents: list[Torrent], matcher: BookMatcher) -> Torrent | None:
27+
def select(
28+
self, book: Book, torrents: Iterable[Torrent], matcher: BookMatcher
29+
) -> Torrent | None:
2730
"""Select the best matching torrent for the given book from the list of torrents"""
28-
torrent_books = [
31+
torrent_books = {
2932
t.book
3033
for t in torrents
3134
if (t.language is None or t.language in self.lang_codes)
3235
and self.wanted_filetypes.intersection(t.file_types or [])
33-
]
36+
}
3437

3538
if not torrent_books:
3639
logger.debug("No torrents for %s passed language and file type filters", book)

src/murid/services/service_factory.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,11 +59,11 @@ def calibre(self):
5959
logger.error("Error initializing Calibre: %s", e)
6060
raise SystemExit(1) from e
6161

62-
def hardcover(self) -> list[Hardcover]:
62+
def hardcover(self) -> set[Hardcover]:
6363
"""Create a list of Hardcover instances for each user specified in the configuration."""
6464
keys = self._load_config()["hardcover_api_keys"]
6565
with ThreadPoolExecutor(max_workers=min(10, len(keys))) as executor:
66-
hardcover_clients = list(executor.map(Hardcover, keys))
66+
hardcover_clients = set(executor.map(Hardcover, keys))
6767
return hardcover_clients
6868

6969
def myanonamouse(self):

src/murid/services/sync_service.py

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from concurrent.futures import ThreadPoolExecutor
66
from datetime import datetime
77
from itertools import chain
8+
from typing import Iterable
89

910
from ..clients.calibre import Calibre
1011
from ..clients.hardcover import Hardcover
@@ -64,43 +65,43 @@ def run(self) -> None:
6465
logger.info("Finished murid cycle")
6566

6667
@staticmethod
67-
def fetch_calibre_books(calibre: Calibre) -> list[Book]:
68+
def fetch_calibre_books(calibre: Calibre) -> set[Book]:
6869
"""Fetch books from the Calibre library."""
6970
books = calibre.get_books()
7071
logger.info("Feched %d books from Calibre database", len(books))
7172
return books
7273

7374
@staticmethod
74-
def fetch_hardcover_books(hardcover: list[Hardcover]) -> list[Book]:
75+
def fetch_hardcover_books(hardcover: list[Hardcover]) -> set[Book]:
7576
"""Fetch books from the Hardcover API."""
7677
with ThreadPoolExecutor(max_workers=min(10, len(hardcover))) as executor:
7778
results = executor.map(lambda client: client.get_books(), hardcover)
78-
books = list(chain.from_iterable(results))
79+
books = set(chain.from_iterable(results))
7980
logger.info("Fetched %d books from Hardcover API", len(books))
8081
return books
8182

8283
@staticmethod
8384
def match_books(
84-
calibre_books: list[Book],
85-
hardcover_books: list[Book],
85+
calibre_books: Iterable[Book],
86+
hardcover_books: Iterable[Book],
8687
matcher: BookMatcher,
87-
) -> list[tuple[Book, Book, float]]:
88+
) -> set[tuple[Book, Book, float]]:
8889
"""Match books from the Hardcover list against the Calibre library."""
8990
matches = matcher.match_books(calibre_books, hardcover_books)
9091
logger.debug("%d books already present in Calibre library", len(matches))
9192
return matches
9293

9394
@staticmethod
9495
def process_books(
95-
present_books: list[tuple[Book, Book, float]],
96-
wanted_books: list[Book],
96+
present_books: Iterable[tuple[Book, Book, float]],
97+
wanted_books: Iterable[Book],
9798
torrent_discovery: TorrentDiscoveryService,
9899
matcher: BookMatcher,
99-
) -> list[tuple[bytes, Book]]:
100+
) -> set[tuple[bytes, Book]]:
100101
"""Process the matched and unmatched books to find torrents for the missing ones."""
101102
matched_ids = {h.id for _, h, _ in present_books}
102-
missing_books = [h for h in wanted_books if h.id not in matched_ids]
103+
missing_books = {h for h in wanted_books if h.id not in matched_ids}
103104

104105
if not missing_books:
105-
return []
106+
return set()
106107
return torrent_discovery.download_torrents(missing_books, matcher)

src/murid/services/torrent_discovery.py

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import logging
44
from concurrent.futures import Future, ThreadPoolExecutor, as_completed
5+
from typing import Iterable
56

67
from ..clients.myanonamouse import MyAnonamouse
78
from ..domain.book import Book
@@ -26,7 +27,7 @@ def __init__(
2627
self.lang_codes = set(lang_codes)
2728
self.torrent_selector = torrent_selector
2829

29-
def find_torrents(self, book: Book) -> tuple[Book, list[Torrent]]:
30+
def find_torrents(self, book: Book) -> tuple[Book, set[Torrent]]:
3031
"""Find torrents for a given book."""
3132
mam_books = self.mam.search_ebook(book.title, book.authors[0] if book.authors else None)
3233

@@ -71,21 +72,21 @@ def submit_downloads(
7172
def collect_downloads(
7273
self,
7374
download_futures: dict[Future, Book],
74-
) -> list[tuple[bytes, Book]]:
75+
) -> set[tuple[bytes, Book]]:
7576
"""Collect the downloaded torrent data once the download futures complete."""
7677

77-
torrent_files = []
78+
torrent_files = set()
7879
for future in as_completed(download_futures):
7980
book = download_futures[future]
8081
torrent_file = future.result()
8182
if torrent_file:
82-
torrent_files.append((torrent_file, book))
83+
torrent_files.add((torrent_file, book))
8384

8485
return torrent_files
8586

8687
def download_torrents(
87-
self, books: list[Book], matcher: BookMatcher
88-
) -> list[tuple[bytes, Book]]:
88+
self, books: Iterable[Book], matcher: BookMatcher
89+
) -> set[tuple[bytes, Book]]:
8990
"""Find and download torrents for a list of books."""
9091
logger.info(
9192
"Searching for torrents for %d book%s", len(books), "s" if len(books) != 1 else ""
@@ -96,5 +97,5 @@ def download_torrents(
9697
download_futures = self.submit_downloads(executor, search_futures, matcher)
9798

9899
if not download_futures:
99-
return []
100+
return set()
100101
return self.collect_downloads(download_futures)

0 commit comments

Comments
 (0)