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.
|
|
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.
|
|
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.
|
|
31
|
+
black = "^24.10"
|
|
32
32
|
isort = "^5.13"
|
|
33
|
-
pre-commit = "^
|
|
33
|
+
pre-commit = "^4.0"
|
|
34
34
|
pytest = "^8.3"
|
|
35
|
-
pytest-cov = "^
|
|
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 (
|
|
36
|
-
• {Status.UPDATE}: update (
|
|
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
|
|
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
|
|
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 =
|
|
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
|
|
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
|
|
180
|
-
"""Return
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
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
|
-
|
|
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
|