sebsync 1.0b1__tar.gz → 1.0.2__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,13 +1,13 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: sebsync
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.0.2
|
|
4
4
|
Summary: Synchronize Standard Ebooks catalog with local EPUB collection.
|
|
5
5
|
Home-page: https://github.com/pbryan/sebsync/
|
|
6
6
|
License: MIT
|
|
7
7
|
Keywords: standard,ebook,book,synchronization,sync,download
|
|
8
8
|
Author: sebsync authors
|
|
9
9
|
Requires-Python: >=3.10,<4.0
|
|
10
|
-
Classifier: Development Status ::
|
|
10
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
11
11
|
Classifier: Environment :: Console
|
|
12
12
|
Classifier: Intended Audience :: End Users/Desktop
|
|
13
13
|
Classifier: License :: OSI Approved :: MIT License
|
|
@@ -8,7 +8,7 @@ target-version = ['py312']
|
|
|
8
8
|
|
|
9
9
|
[tool.poetry]
|
|
10
10
|
name = "sebsync"
|
|
11
|
-
version = "1.
|
|
11
|
+
version = "1.0.2"
|
|
12
12
|
description = "Synchronize Standard Ebooks catalog with local EPUB collection."
|
|
13
13
|
readme = "README.md"
|
|
14
14
|
authors = ["sebsync authors"]
|
|
@@ -16,7 +16,7 @@ homepage = "https://github.com/pbryan/sebsync/"
|
|
|
16
16
|
license = "MIT"
|
|
17
17
|
keywords = ["standard", "ebook", "book", "synchronization", "sync", "download"]
|
|
18
18
|
classifiers = [
|
|
19
|
-
"Development Status ::
|
|
19
|
+
"Development Status :: 5 - Production/Stable",
|
|
20
20
|
"Environment :: Console",
|
|
21
21
|
"Intended Audience :: End Users/Desktop",
|
|
22
22
|
"Topic :: System :: Archiving :: Mirroring",
|
|
@@ -28,9 +28,9 @@ click = "^8.1"
|
|
|
28
28
|
requests = "^2.32"
|
|
29
29
|
|
|
30
30
|
[tool.poetry.dev-dependencies]
|
|
31
|
-
black = "^24.
|
|
31
|
+
black = "^24.8"
|
|
32
32
|
isort = "^5.13"
|
|
33
|
-
pre-commit = "^3.
|
|
33
|
+
pre-commit = "^3.8"
|
|
34
34
|
pytest = "^8.3"
|
|
35
35
|
pytest-cov = "^5.0"
|
|
36
36
|
|
|
@@ -44,3 +44,4 @@ no_sections = true
|
|
|
44
44
|
|
|
45
45
|
[tool.poetry.scripts]
|
|
46
46
|
sebsync = "sebsync:main"
|
|
47
|
+
|
|
@@ -9,6 +9,7 @@ from dataclasses import dataclass
|
|
|
9
9
|
from datetime import datetime, timezone
|
|
10
10
|
from pathlib import Path
|
|
11
11
|
from requests.auth import HTTPBasicAuth
|
|
12
|
+
from urllib.parse import urlparse
|
|
12
13
|
|
|
13
14
|
|
|
14
15
|
_dry_run: bool = False
|
|
@@ -20,18 +21,23 @@ class Status:
|
|
|
20
21
|
NEW = click.style("N", fg="green")
|
|
21
22
|
UPDATE = click.style("U", fg="blue")
|
|
22
23
|
EXTRA = click.style("X", fg="yellow")
|
|
24
|
+
UNKNOWN = click.style("?", fg="yellow")
|
|
23
25
|
|
|
24
26
|
|
|
25
27
|
_epilog = f"""
|
|
26
|
-
|
|
28
|
+
\b
|
|
29
|
+
Download naming conventions:
|
|
30
|
+
• standard: Standard Ebooks' naming (e.g. "edwin-a-abbott_flatland.epub")
|
|
31
|
+
• sortable: sortable author/title (e.g. "Abbott, Edwin A. - Flatland.epub")
|
|
27
32
|
|
|
28
33
|
\b
|
|
29
|
-
|
|
30
|
-
{Status.
|
|
31
|
-
{Status.
|
|
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)
|
|
37
|
+
• {Status.EXTRA}: extraneous (local ebook not found in Standard Ebooks catalog)
|
|
38
|
+
• {Status.UNKNOWN}: unknown (local ebook could not be processed)
|
|
32
39
|
|
|
33
|
-
|
|
34
|
-
published ebook. It's a rare occurrence, and it's generally safe to delete such files.
|
|
40
|
+
See https://github.com/pbryan/sebsync/ for updates, bug reports and answers.
|
|
35
41
|
"""
|
|
36
42
|
|
|
37
43
|
|
|
@@ -99,6 +105,8 @@ def get_remote_ebooks(opds_url: str, email: str, type: str) -> dict[str, Standar
|
|
|
99
105
|
updated=fromisoformat(entry.find("atom:updated", ns).text),
|
|
100
106
|
)
|
|
101
107
|
ebooks[ebook.id] = ebook
|
|
108
|
+
if not ebooks:
|
|
109
|
+
raise click.ClickException("OPDS catalog download failed. Is email address correct?")
|
|
102
110
|
return ebooks
|
|
103
111
|
|
|
104
112
|
|
|
@@ -106,50 +114,76 @@ def get_local_ebooks(dir: Path) -> dict[str, LocalEbook]:
|
|
|
106
114
|
"""Return metadata of Standard EPUBs in the specified directory and subdirectories."""
|
|
107
115
|
ebooks = {}
|
|
108
116
|
for path in dir.glob("**/*.epub"):
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
117
|
+
if not path.is_file():
|
|
118
|
+
continue
|
|
119
|
+
try:
|
|
120
|
+
with zipfile.ZipFile(path) as zip:
|
|
121
|
+
with zip.open("META-INF/container.xml") as file:
|
|
122
|
+
root = ElementTree.parse(file)
|
|
123
|
+
ns = {"container": "urn:oasis:names:tc:opendocument:xmlns:container"}
|
|
124
|
+
rootfile = root.find(".//container:rootfile", ns).attrib["full-path"]
|
|
125
|
+
with zip.open(rootfile) as file:
|
|
126
|
+
root = ElementTree.parse(file)
|
|
127
|
+
ns = {
|
|
128
|
+
"opf": "http://www.idpf.org/2007/opf",
|
|
129
|
+
"dc": "http://purl.org/dc/elements/1.1/",
|
|
130
|
+
}
|
|
131
|
+
metadata = root.find("opf:metadata", ns)
|
|
132
|
+
id = metadata.find("dc:identifier", ns)
|
|
133
|
+
if id is None or "standardebooks.org" not in id.text:
|
|
134
|
+
continue
|
|
135
|
+
modified = metadata.find(".//opf:meta[@property='dcterms:modified']", ns)
|
|
136
|
+
ebook = LocalEbook(
|
|
137
|
+
id=id.text,
|
|
138
|
+
title=metadata.find(".//dc:title", ns).text,
|
|
139
|
+
path=path,
|
|
140
|
+
modified=fromisoformat(modified.text),
|
|
141
|
+
)
|
|
142
|
+
ebooks[ebook.id] = ebook
|
|
143
|
+
except:
|
|
144
|
+
echo_status(path, Status.UNKNOWN)
|
|
132
145
|
return ebooks
|
|
133
146
|
|
|
134
147
|
|
|
135
148
|
def download_ebook(url: str, path: Path) -> None:
|
|
136
149
|
"""Download the ebook at the specified URL into the specified path."""
|
|
137
|
-
if
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
150
|
+
if _dry_run:
|
|
151
|
+
return
|
|
152
|
+
download = path.with_suffix(".sebsync")
|
|
153
|
+
response = requests.get(url, stream=True)
|
|
154
|
+
with download.open("wb") as file:
|
|
155
|
+
for chunk in response.iter_content(chunk_size=1 * 1024 * 1024):
|
|
156
|
+
file.write(chunk)
|
|
157
|
+
download.replace(path)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def sortable_author(author: str) -> str:
|
|
161
|
+
"""Return the sortable name of the given author."""
|
|
162
|
+
suffixes = {"Jr.", "Sr.", "Esq.", "PhD"}
|
|
163
|
+
split = author.split()
|
|
164
|
+
if len(split) < 2:
|
|
165
|
+
return author
|
|
166
|
+
last = split.pop().rstrip(",")
|
|
167
|
+
suffix = None
|
|
168
|
+
if last in suffixes:
|
|
169
|
+
suffix = last
|
|
170
|
+
last = split.pop()
|
|
171
|
+
result = last
|
|
172
|
+
if split:
|
|
173
|
+
result += f", {' '.join(split)}"
|
|
174
|
+
if suffix:
|
|
175
|
+
result += f", {suffix}"
|
|
176
|
+
return result
|
|
142
177
|
|
|
143
178
|
|
|
144
179
|
def ebook_filename(ebook: StandardEbook) -> str:
|
|
145
|
-
"""Return an
|
|
180
|
+
"""Return an EPUB file name for Standard ebook."""
|
|
146
181
|
replace = {"/": "-", "‘": "'", "’": "'", '"': "'", "“": "'", "”": "'"}
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
result = f"{author} - {title}.epub"
|
|
182
|
+
match _naming:
|
|
183
|
+
case "standard":
|
|
184
|
+
result = Path(urlparse(ebook.href).path).name
|
|
185
|
+
case "sortable":
|
|
186
|
+
result = f"{sortable_author(ebook.author)} - {ebook.title}.epub"
|
|
153
187
|
for k, v in replace.items():
|
|
154
188
|
result = result.replace(k, v)
|
|
155
189
|
return result
|
|
@@ -168,6 +202,11 @@ def ebook_filename(ebook: StandardEbook) -> str:
|
|
|
168
202
|
type=click.Path(exists=True, file_okay=False, dir_okay=True, writable=True, path_type=Path),
|
|
169
203
|
default=if_exists(Path.home() / "Downloads"),
|
|
170
204
|
)
|
|
205
|
+
@click.option(
|
|
206
|
+
"--force-update",
|
|
207
|
+
help="Force update of all local ebooks.",
|
|
208
|
+
is_flag=True,
|
|
209
|
+
)
|
|
171
210
|
@click.option(
|
|
172
211
|
"--dry-run",
|
|
173
212
|
help="Perform a trial run with no changes made.",
|
|
@@ -179,6 +218,12 @@ def ebook_filename(ebook: StandardEbook) -> str:
|
|
|
179
218
|
required=True,
|
|
180
219
|
)
|
|
181
220
|
@click.help_option()
|
|
221
|
+
@click.option(
|
|
222
|
+
"--naming",
|
|
223
|
+
type=click.Choice(["standard", "sortable"]),
|
|
224
|
+
help="Download file naming convention.",
|
|
225
|
+
default="standard",
|
|
226
|
+
)
|
|
182
227
|
@click.option(
|
|
183
228
|
"--opds",
|
|
184
229
|
help="URL of Standard Ebooks OPDS catalog.",
|
|
@@ -191,7 +236,7 @@ def ebook_filename(ebook: StandardEbook) -> str:
|
|
|
191
236
|
)
|
|
192
237
|
@click.option(
|
|
193
238
|
"--type",
|
|
194
|
-
type=click.Choice(type_selector.keys()),
|
|
239
|
+
type=click.Choice(list(type_selector.keys())),
|
|
195
240
|
help="EPUB type to download.",
|
|
196
241
|
default="compatible",
|
|
197
242
|
)
|
|
@@ -202,10 +247,12 @@ def ebook_filename(ebook: StandardEbook) -> str:
|
|
|
202
247
|
)
|
|
203
248
|
@click.version_option(package_name="sebsync")
|
|
204
249
|
def sebsync(
|
|
205
|
-
books:
|
|
206
|
-
downloads:
|
|
250
|
+
books: Path,
|
|
251
|
+
downloads: Path,
|
|
207
252
|
dry_run: bool,
|
|
208
253
|
email: str,
|
|
254
|
+
force_update: bool,
|
|
255
|
+
naming: str,
|
|
209
256
|
opds: str,
|
|
210
257
|
quiet: bool,
|
|
211
258
|
type: str,
|
|
@@ -214,14 +261,14 @@ def sebsync(
|
|
|
214
261
|
global _dry_run
|
|
215
262
|
global _verbose
|
|
216
263
|
global _quiet
|
|
264
|
+
global _naming
|
|
217
265
|
|
|
218
266
|
_dry_run = dry_run
|
|
219
|
-
|
|
267
|
+
_naming = naming
|
|
220
268
|
_quiet = quiet
|
|
269
|
+
_verbose = verbose and not quiet # quiet wins
|
|
221
270
|
|
|
222
271
|
remote_ebooks = get_remote_ebooks(opds, email, type)
|
|
223
|
-
if not remote_ebooks:
|
|
224
|
-
raise click.ClickException("OPDS download failed. Is email address correct?")
|
|
225
272
|
if _verbose:
|
|
226
273
|
click.echo(f"Found {len(remote_ebooks)} remote ebooks.")
|
|
227
274
|
|
|
@@ -231,7 +278,7 @@ def sebsync(
|
|
|
231
278
|
|
|
232
279
|
for remote_ebook in remote_ebooks.values():
|
|
233
280
|
if local_ebook := local_ebooks.get(remote_ebook.id):
|
|
234
|
-
if remote_ebook.updated != local_ebook.modified:
|
|
281
|
+
if remote_ebook.updated != local_ebook.modified or force_update:
|
|
235
282
|
echo_status(local_ebook.path, Status.UPDATE)
|
|
236
283
|
download_ebook(remote_ebook.href, local_ebook.path)
|
|
237
284
|
else:
|
|
@@ -245,7 +292,7 @@ def sebsync(
|
|
|
245
292
|
|
|
246
293
|
|
|
247
294
|
def main():
|
|
248
|
-
sebsync(
|
|
295
|
+
sebsync(show_default=True)
|
|
249
296
|
|
|
250
297
|
|
|
251
298
|
if __name__ == "__main__":
|
|
File without changes
|
|
File without changes
|
|
File without changes
|