sebsync 1.0.2__tar.gz → 1.0.4__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: sebsync
3
- Version: 1.0.2
3
+ Version: 1.0.4
4
4
  Summary: Synchronize Standard Ebooks catalog with local EPUB collection.
5
5
  Home-page: https://github.com/pbryan/sebsync/
6
6
  License: MIT
@@ -15,6 +15,7 @@ Classifier: Programming Language :: Python :: 3
15
15
  Classifier: Programming Language :: Python :: 3.10
16
16
  Classifier: Programming Language :: Python :: 3.11
17
17
  Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
18
19
  Classifier: Topic :: System :: Archiving :: Mirroring
19
20
  Requires-Dist: click (>=8.1,<9.0)
20
21
  Requires-Dist: requests (>=2.32,<3.0)
@@ -8,7 +8,7 @@ target-version = ['py312']
8
8
 
9
9
  [tool.poetry]
10
10
  name = "sebsync"
11
- version = "1.0.2"
11
+ version = "1.0.4"
12
12
  description = "Synchronize Standard Ebooks catalog with local EPUB collection."
13
13
  readme = "README.md"
14
14
  authors = ["sebsync authors"]
@@ -28,11 +28,11 @@ click = "^8.1"
28
28
  requests = "^2.32"
29
29
 
30
30
  [tool.poetry.dev-dependencies]
31
- black = "^24.8"
31
+ black = "^24.10"
32
32
  isort = "^5.13"
33
- pre-commit = "^3.8"
33
+ pre-commit = "^4.0"
34
34
  pytest = "^8.3"
35
- pytest-cov = "^5.0"
35
+ pytest-cov = "^6.0"
36
36
 
37
37
  [tool.isort]
38
38
  profile = "black"
@@ -44,4 +44,3 @@ no_sections = true
44
44
 
45
45
  [tool.poetry.scripts]
46
46
  sebsync = "sebsync:main"
47
-
@@ -32,8 +32,8 @@ _epilog = f"""
32
32
 
33
33
  \b
34
34
  Reported file statuses:
35
- • {Status.NEW}: new (downloads the new ebook to downloads directory)
36
- • {Status.UPDATE}: update (overwrites the existing local ebook with new version)
35
+ • {Status.NEW}: new (new ebook downloaded to downloads directory)
36
+ • {Status.UPDATE}: update (local ebook updated with newer version)
37
37
  • {Status.EXTRA}: extraneous (local ebook not found in Standard Ebooks catalog)
38
38
  • {Status.UNKNOWN}: unknown (local ebook could not be processed)
39
39
 
@@ -42,7 +42,7 @@ _epilog = f"""
42
42
 
43
43
 
44
44
  @dataclass
45
- class StandardEbook:
45
+ class RemoteEbook:
46
46
  """Metadata for ebook in Standard Ebooks OPDS catalog."""
47
47
 
48
48
  id: str
@@ -89,7 +89,7 @@ def fromisoformat(text: str) -> datetime:
89
89
  )
90
90
 
91
91
 
92
- def get_remote_ebooks(opds_url: str, email: str, type: str) -> dict[str, StandardEbook]:
92
+ def catalog_remote_ebooks(opds_url: str, email: str, type: str) -> dict[str, RemoteEbook]:
93
93
  """Return Standard Ebooks metadata for EPUBs from the OPDS catalog."""
94
94
  ns = {"atom": "http://www.w3.org/2005/Atom", "dc": "http://purl.org/dc/terms/"}
95
95
  ebooks = {}
@@ -97,7 +97,7 @@ def get_remote_ebooks(opds_url: str, email: str, type: str) -> dict[str, Standar
97
97
  response.raw.decode_content = True
98
98
  root = ElementTree.parse(response.raw).getroot()
99
99
  for entry in root.iterfind(".//atom:entry", ns):
100
- ebook = StandardEbook(
100
+ ebook = RemoteEbook(
101
101
  id=entry.find("dc:identifier", ns).text,
102
102
  title=entry.find("atom:title", ns).text,
103
103
  author=entry.find("atom:author", ns).find("atom:name", ns).text,
@@ -110,7 +110,7 @@ def get_remote_ebooks(opds_url: str, email: str, type: str) -> dict[str, Standar
110
110
  return ebooks
111
111
 
112
112
 
113
- def get_local_ebooks(dir: Path) -> dict[str, LocalEbook]:
113
+ def catalog_local_ebooks(dir: Path) -> dict[str, LocalEbook]:
114
114
  """Return metadata of Standard EPUBs in the specified directory and subdirectories."""
115
115
  ebooks = {}
116
116
  for path in dir.glob("**/*.epub"):
@@ -145,8 +145,9 @@ def get_local_ebooks(dir: Path) -> dict[str, LocalEbook]:
145
145
  return ebooks
146
146
 
147
147
 
148
- def download_ebook(url: str, path: Path) -> None:
148
+ def download_ebook(url: str, path: Path, status: str) -> None:
149
149
  """Download the ebook at the specified URL into the specified path."""
150
+ echo_status(path, status)
150
151
  if _dry_run:
151
152
  return
152
153
  download = path.with_suffix(".sebsync")
@@ -176,8 +177,29 @@ def sortable_author(author: str) -> str:
176
177
  return result
177
178
 
178
179
 
179
- def ebook_filename(ebook: StandardEbook) -> str:
180
- """Return an EPUB file name for Standard ebook."""
180
+ def books_are_different(local_ebook: LocalEbook, remote_ebook: RemoteEbook) -> bool:
181
+ """Return if differences are detected between local and remote ebooks."""
182
+
183
+ # if metadata has exact modification times, then local is considered current
184
+ if remote_ebook.updated == local_ebook.modified:
185
+ return False
186
+
187
+ stat = local_ebook.path.stat()
188
+
189
+ file_modified = datetime.fromtimestamp(stat.st_mtime, timezone.utc)
190
+ if remote_ebook.updated > file_modified:
191
+ return True
192
+
193
+ response = requests.head(remote_ebook.href)
194
+ content_length = int(response.headers["Content-Length"])
195
+ if content_length != stat.st_size:
196
+ return True
197
+
198
+ return False
199
+
200
+
201
+ def ebook_filename(ebook: RemoteEbook) -> str:
202
+ """Return an EPUB file name for remote ebook."""
181
203
  replace = {"/": "-", "‘": "'", "’": "'", '"': "'", "“": "'", "”": "'"}
182
204
  match _naming:
183
205
  case "standard":
@@ -202,11 +224,6 @@ def ebook_filename(ebook: StandardEbook) -> str:
202
224
  type=click.Path(exists=True, file_okay=False, dir_okay=True, writable=True, path_type=Path),
203
225
  default=if_exists(Path.home() / "Downloads"),
204
226
  )
205
- @click.option(
206
- "--force-update",
207
- help="Force update of all local ebooks.",
208
- is_flag=True,
209
- )
210
227
  @click.option(
211
228
  "--dry-run",
212
229
  help="Perform a trial run with no changes made.",
@@ -217,6 +234,11 @@ def ebook_filename(ebook: StandardEbook) -> str:
217
234
  help="Email address to authenticate with Standard Ebooks.",
218
235
  required=True,
219
236
  )
237
+ @click.option(
238
+ "--force-update",
239
+ help="Force update of all local ebooks (implies --update).",
240
+ is_flag=True,
241
+ )
220
242
  @click.help_option()
221
243
  @click.option(
222
244
  "--naming",
@@ -240,6 +262,11 @@ def ebook_filename(ebook: StandardEbook) -> str:
240
262
  help="EPUB type to download.",
241
263
  default="compatible",
242
264
  )
265
+ @click.option(
266
+ "--update/--no-update",
267
+ help="Download updates into existing local ebook files.",
268
+ default=True,
269
+ )
243
270
  @click.option(
244
271
  "--verbose",
245
272
  help="Increase verbosity.",
@@ -256,8 +283,12 @@ def sebsync(
256
283
  opds: str,
257
284
  quiet: bool,
258
285
  type: str,
286
+ update: bool,
259
287
  verbose: bool,
260
288
  ):
289
+ if force_update:
290
+ update = True
291
+
261
292
  global _dry_run
262
293
  global _verbose
263
294
  global _quiet
@@ -268,23 +299,25 @@ def sebsync(
268
299
  _quiet = quiet
269
300
  _verbose = verbose and not quiet # quiet wins
270
301
 
271
- remote_ebooks = get_remote_ebooks(opds, email, type)
302
+ remote_ebooks = catalog_remote_ebooks(opds, email, type)
272
303
  if _verbose:
273
304
  click.echo(f"Found {len(remote_ebooks)} remote ebooks.")
274
305
 
275
- local_ebooks = get_local_ebooks(downloads) | get_local_ebooks(books)
306
+ local_ebooks = catalog_local_ebooks(books)
276
307
  if _verbose:
277
308
  click.echo(f"Found {len(local_ebooks)} local ebooks.")
278
309
 
279
310
  for remote_ebook in remote_ebooks.values():
280
- if local_ebook := local_ebooks.get(remote_ebook.id):
281
- if remote_ebook.updated != local_ebook.modified or force_update:
282
- echo_status(local_ebook.path, Status.UPDATE)
283
- download_ebook(remote_ebook.href, local_ebook.path)
311
+ local_ebook = local_ebooks.get(remote_ebook.id)
312
+ download_path = downloads / ebook_filename(remote_ebook)
313
+ if local_ebook:
314
+ if force_update or books_are_different(local_ebook, remote_ebook):
315
+ if update:
316
+ download_ebook(remote_ebook.href, local_ebook.path, Status.UPDATE)
317
+ else:
318
+ download_ebook(remote_ebook.href, download_path, Status.NEW)
284
319
  else:
285
- path = downloads / ebook_filename(remote_ebook)
286
- echo_status(path, Status.NEW)
287
- download_ebook(remote_ebook.href, path)
320
+ download_ebook(remote_ebook.href, download_path, Status.NEW)
288
321
 
289
322
  for local_ebook in local_ebooks.values():
290
323
  if local_ebook.id not in remote_ebooks:
File without changes
File without changes
File without changes