sebsync 1.0.0__py3-none-any.whl
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.
- sebsync-1.0.0.dist-info/AUTHORS.md +3 -0
- sebsync-1.0.0.dist-info/LICENSE +19 -0
- sebsync-1.0.0.dist-info/METADATA +83 -0
- sebsync-1.0.0.dist-info/RECORD +7 -0
- sebsync-1.0.0.dist-info/WHEEL +4 -0
- sebsync-1.0.0.dist-info/entry_points.txt +3 -0
- sebsync.py +271 -0
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
© 2024 sebsync authors
|
|
2
|
+
|
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
4
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
5
|
+
in the Software without restriction, including without limitation the rights
|
|
6
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
7
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
8
|
+
furnished to do so, subject to the following conditions:
|
|
9
|
+
|
|
10
|
+
The above copyright notice and this permission notice shall be included in all
|
|
11
|
+
copies or substantial portions of the Software.
|
|
12
|
+
|
|
13
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
14
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
15
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
16
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
17
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
18
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
19
|
+
SOFTWARE.
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: sebsync
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Synchronize Standard Ebooks catalog with local EPUB collection.
|
|
5
|
+
Home-page: https://github.com/pbryan/sebsync/
|
|
6
|
+
License: MIT
|
|
7
|
+
Keywords: standard,ebook,book,synchronization,sync,download
|
|
8
|
+
Author: sebsync authors
|
|
9
|
+
Requires-Python: >=3.10,<4.0
|
|
10
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
11
|
+
Classifier: Environment :: Console
|
|
12
|
+
Classifier: Intended Audience :: End Users/Desktop
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Topic :: System :: Archiving :: Mirroring
|
|
19
|
+
Requires-Dist: click (>=8.1,<9.0)
|
|
20
|
+
Requires-Dist: requests (>=2.32,<3.0)
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
|
|
23
|
+
# sebsync
|
|
24
|
+
|
|
25
|
+
sebsync is a simple script to synchronize the [Standard Ebooks](https://standardebooks.org/)
|
|
26
|
+
catalog with your local EPUB collection.
|
|
27
|
+
|
|
28
|
+
## How it works
|
|
29
|
+
|
|
30
|
+
The script reads the Standard Ebooks [OPDS feed](https://standardebooks.org/feeds) and
|
|
31
|
+
reconciles the ebooks in their catalog against the EPUBs in your local filesystem,
|
|
32
|
+
downloading new ones or updating existing ones as necessary.
|
|
33
|
+
|
|
34
|
+
If a new ebook is detected in the catalog, then it will be downloaded into your downloads
|
|
35
|
+
directory. If an updated version of a book is detected in the catalog that is already in your
|
|
36
|
+
local filesystem, then the updated version will be downloaded and stored in the existing local
|
|
37
|
+
file.
|
|
38
|
+
|
|
39
|
+
The script also detects extraneous local ebooks (local ebooks not found in the Standard
|
|
40
|
+
Ebooks catalog). This can can occur when Standard Ebooks changes the identifier of a previously
|
|
41
|
+
published ebook. It's a rare occurrence, and it's generally safe to delete such files.
|
|
42
|
+
|
|
43
|
+
## Requirements
|
|
44
|
+
|
|
45
|
+
This script uses Standard Ebooks' OPDS feed to access metadata on all books their catalog. In
|
|
46
|
+
order to do so, you need to be a member of the Standard Ebooks
|
|
47
|
+
[Patrons Circle](https://standardebooks.org/donate#patrons-circle) (or have previously produced
|
|
48
|
+
an ebook for Standard Ebooks). If you're not already a Standard Ebooks patron, please consider
|
|
49
|
+
becoming one. It's a tremendously valuable project, deserving of your support.
|
|
50
|
+
|
|
51
|
+
## Installation
|
|
52
|
+
|
|
53
|
+
```
|
|
54
|
+
pipx install sebsync
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Example usage
|
|
58
|
+
|
|
59
|
+
```
|
|
60
|
+
sebsync --email addr@example.com --books /home/user/MyBooks --downloads /home/user/MyBooks/Downloads
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Questions and answers
|
|
64
|
+
|
|
65
|
+
Q1. *Why use a separate downloads directory for new ebooks?*
|
|
66
|
+
|
|
67
|
+
A1. This is a feature for those who want to easily recognize new ebooks, and to manually
|
|
68
|
+
rename and/or categorize them within their library. If this feature is not useful for you,
|
|
69
|
+
simply set `--downloads` to be the same directory as `--books`.
|
|
70
|
+
|
|
71
|
+
Q2. *What is pipx?*
|
|
72
|
+
|
|
73
|
+
A2. [pipx](https://pipx.pypa.io/) is a utility that allows Python packages like sebsync to
|
|
74
|
+
be installed in an isolated environment. This makes it really easy to install and run such
|
|
75
|
+
scripts without interfering with other Python package installations.
|
|
76
|
+
|
|
77
|
+
Q3. *Does this script support Kindle books?*
|
|
78
|
+
|
|
79
|
+
A3. Not presently. A cursory analysis of the Standard Ebooks AZW3 structure suggests that there
|
|
80
|
+
currently isn't a reliable method to reconcile Kindle ebooks with the OPDS feed catalog. If
|
|
81
|
+
this feature is important to you, please let us know by voting for
|
|
82
|
+
[this issue](https://github.com/pbryan/sebsync/issues/2) in GitHub.
|
|
83
|
+
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
sebsync.py,sha256=u1IDAS_Z86orsx__hpSzjtO68X1cJZs-FLAZry8S-kQ,8426
|
|
2
|
+
sebsync-1.0.0.dist-info/AUTHORS.md,sha256=Ynzo5ZrXeZI7TPmSWz7ZyKWaQKkWB96AHpw-_ihHhiI,65
|
|
3
|
+
sebsync-1.0.0.dist-info/LICENSE,sha256=2nOKwUpN7p1uc32KrEnDt1EVY16pFgeIyV1iyQr0Kw8,1048
|
|
4
|
+
sebsync-1.0.0.dist-info/METADATA,sha256=CtkSsLheBoKA6BQA6RUoE7fLBM0VMrc7l7S3GaNma1o,3406
|
|
5
|
+
sebsync-1.0.0.dist-info/WHEEL,sha256=sP946D7jFCHeNz5Iq4fL4Lu-PrWrFsgfLXbbkciIZwg,88
|
|
6
|
+
sebsync-1.0.0.dist-info/entry_points.txt,sha256=vLL74SO0m9xORcjSlCvDwkgJe3qSqSWB13VAitn2mxc,40
|
|
7
|
+
sebsync-1.0.0.dist-info/RECORD,,
|
sebsync.py
ADDED
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
"""Synchronize Standard Ebooks catalog with local EPUB collection."""
|
|
2
|
+
|
|
3
|
+
import click
|
|
4
|
+
import requests
|
|
5
|
+
import xml.etree.ElementTree as ElementTree
|
|
6
|
+
import zipfile
|
|
7
|
+
|
|
8
|
+
from contextlib import suppress
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from datetime import datetime, timezone
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from requests.auth import HTTPBasicAuth
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
_dry_run: bool = False
|
|
16
|
+
_quiet: bool = False
|
|
17
|
+
_verbose: bool = False
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Status:
|
|
21
|
+
NEW = click.style("N", fg="green")
|
|
22
|
+
UPDATE = click.style("U", fg="blue")
|
|
23
|
+
EXTRA = click.style("X", fg="yellow")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
_epilog = f"""
|
|
27
|
+
The following statuses are reported:
|
|
28
|
+
|
|
29
|
+
\b
|
|
30
|
+
{Status.NEW}: new (downloads the new ebook to downloads directory)
|
|
31
|
+
{Status.UPDATE}: update (overwrites the existing local ebook with new version)
|
|
32
|
+
{Status.EXTRA}: extraneous (local ebook was not found in Standard Ebooks catalog)
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class StandardEbook:
|
|
38
|
+
"""Metadata for ebook in Standard Ebooks OPDS catalog."""
|
|
39
|
+
|
|
40
|
+
id: str
|
|
41
|
+
title: str
|
|
42
|
+
author: str
|
|
43
|
+
href: str
|
|
44
|
+
updated: datetime
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class LocalEbook:
|
|
49
|
+
"""Metadata for ebook in local directory."""
|
|
50
|
+
|
|
51
|
+
id: str
|
|
52
|
+
title: str
|
|
53
|
+
path: Path
|
|
54
|
+
modified: datetime
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
# map type selection to link title in OPDS catalog
|
|
58
|
+
type_selector = {
|
|
59
|
+
"compatible": "Recommended compatible epub",
|
|
60
|
+
"kobo": "Kobo Kepub epub",
|
|
61
|
+
"advanced": "Advanced epub",
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def echo_status(path: Path, status: str) -> None:
|
|
66
|
+
if not _quiet:
|
|
67
|
+
click.echo(f"{status} {path}")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def if_exists(path: Path) -> Path | None:
|
|
71
|
+
return path if path.exists() else None
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def fromisoformat(text: str) -> datetime:
|
|
75
|
+
"""Convert RFC 3339 string into datetime; compatible with Python 3.10."""
|
|
76
|
+
if not text.endswith("Z"):
|
|
77
|
+
raise ValueError("expecting RFC 3339 formatted string")
|
|
78
|
+
d = datetime.fromisoformat(text.rstrip("Z"))
|
|
79
|
+
return datetime(
|
|
80
|
+
d.year, d.month, d.day, d.hour, d.minute, d.second, d.microsecond, timezone.utc
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def get_remote_ebooks(opds_url: str, email: str, type: str) -> dict[str, StandardEbook]:
|
|
85
|
+
"""Return Standard Ebooks metadata for EPUBs from the OPDS catalog."""
|
|
86
|
+
ns = {"atom": "http://www.w3.org/2005/Atom", "dc": "http://purl.org/dc/terms/"}
|
|
87
|
+
ebooks = {}
|
|
88
|
+
response = requests.get(opds_url, stream=True, auth=HTTPBasicAuth(email, ""))
|
|
89
|
+
response.raw.decode_content = True
|
|
90
|
+
root = ElementTree.parse(response.raw).getroot()
|
|
91
|
+
for entry in root.iterfind(".//atom:entry", ns):
|
|
92
|
+
ebook = StandardEbook(
|
|
93
|
+
id=entry.find("dc:identifier", ns).text,
|
|
94
|
+
title=entry.find("atom:title", ns).text,
|
|
95
|
+
author=entry.find("atom:author", ns).find("atom:name", ns).text,
|
|
96
|
+
href=entry.find(f".//atom:link[@title='{type_selector[type]}']", ns).attrib["href"],
|
|
97
|
+
updated=fromisoformat(entry.find("atom:updated", ns).text),
|
|
98
|
+
)
|
|
99
|
+
ebooks[ebook.id] = ebook
|
|
100
|
+
return ebooks
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def get_local_ebooks(dir: Path) -> dict[str, LocalEbook]:
|
|
104
|
+
"""Return metadata of Standard EPUBs in the specified directory and subdirectories."""
|
|
105
|
+
ebooks = {}
|
|
106
|
+
for path in dir.glob("**/*.epub"):
|
|
107
|
+
with suppress(): # ignore invalid ebook
|
|
108
|
+
with zipfile.ZipFile(path) as zip:
|
|
109
|
+
with zip.open("META-INF/container.xml") as file:
|
|
110
|
+
root = ElementTree.parse(file)
|
|
111
|
+
ns = {"container": "urn:oasis:names:tc:opendocument:xmlns:container"}
|
|
112
|
+
rootfile = root.find(".//container:rootfile", ns).attrib["full-path"]
|
|
113
|
+
with zip.open(rootfile) as file:
|
|
114
|
+
root = ElementTree.parse(file)
|
|
115
|
+
ns = {
|
|
116
|
+
"opf": "http://www.idpf.org/2007/opf",
|
|
117
|
+
"dc": "http://purl.org/dc/elements/1.1/",
|
|
118
|
+
}
|
|
119
|
+
metadata = root.find("opf:metadata", ns)
|
|
120
|
+
id = metadata.find("dc:identifier", ns)
|
|
121
|
+
if id is None or "standardebooks.org" not in id.text:
|
|
122
|
+
continue
|
|
123
|
+
modified = metadata.find(".//opf:meta[@property='dcterms:modified']", ns)
|
|
124
|
+
ebook = LocalEbook(
|
|
125
|
+
id=id.text,
|
|
126
|
+
title=metadata.find(".//dc:title", ns).text,
|
|
127
|
+
path=path,
|
|
128
|
+
modified=fromisoformat(modified.text),
|
|
129
|
+
)
|
|
130
|
+
ebooks[ebook.id] = ebook
|
|
131
|
+
return ebooks
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def download_ebook(url: str, path: Path) -> None:
|
|
135
|
+
"""Download the ebook at the specified URL into the specified path."""
|
|
136
|
+
if not _dry_run:
|
|
137
|
+
response = requests.get(url, stream=True)
|
|
138
|
+
with path.open("wb") as file:
|
|
139
|
+
for chunk in response.iter_content(chunk_size=1 * 1024 * 1024):
|
|
140
|
+
file.write(chunk)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def sortable_author(author: str) -> str:
|
|
144
|
+
"""Return the sortable name of the given author."""
|
|
145
|
+
suffixes = {"Jr.", "Sr.", "Esq.", "PhD"}
|
|
146
|
+
split = author.split()
|
|
147
|
+
if len(split) < 2:
|
|
148
|
+
return author
|
|
149
|
+
last = split.pop().rstrip(",")
|
|
150
|
+
suffix = None
|
|
151
|
+
if last in suffixes:
|
|
152
|
+
suffix = last
|
|
153
|
+
last = split.pop()
|
|
154
|
+
result = last
|
|
155
|
+
if split:
|
|
156
|
+
result += f", {' '.join(split)}"
|
|
157
|
+
if suffix:
|
|
158
|
+
result += f", {suffix}"
|
|
159
|
+
return result
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def ebook_filename(ebook: StandardEbook) -> str:
|
|
163
|
+
"""Return an appropriate EPUB file name for the given Standard Ebooks author and title."""
|
|
164
|
+
replace = {"/": "-", "‘": "'", "’": "'", '"': "'", "“": "'", "”": "'"}
|
|
165
|
+
result = f"{sortable_author(ebook.author)} - {ebook.title}.epub"
|
|
166
|
+
for k, v in replace.items():
|
|
167
|
+
result = result.replace(k, v)
|
|
168
|
+
return result
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
@click.command(help=__doc__, epilog=_epilog)
|
|
172
|
+
@click.option(
|
|
173
|
+
"--books",
|
|
174
|
+
help="Directory where local books are stored.",
|
|
175
|
+
type=click.Path(exists=True, file_okay=False, dir_okay=True, writable=True, path_type=Path),
|
|
176
|
+
default=if_exists(Path.home() / "Books"),
|
|
177
|
+
)
|
|
178
|
+
@click.option(
|
|
179
|
+
"--downloads",
|
|
180
|
+
help="Directory where new ebooks are downloaded.",
|
|
181
|
+
type=click.Path(exists=True, file_okay=False, dir_okay=True, writable=True, path_type=Path),
|
|
182
|
+
default=if_exists(Path.home() / "Downloads"),
|
|
183
|
+
)
|
|
184
|
+
@click.option(
|
|
185
|
+
"--force-update",
|
|
186
|
+
help="Force update of all local ebooks.",
|
|
187
|
+
is_flag=True,
|
|
188
|
+
)
|
|
189
|
+
@click.option(
|
|
190
|
+
"--dry-run",
|
|
191
|
+
help="Perform a trial run with no changes made.",
|
|
192
|
+
is_flag=True,
|
|
193
|
+
)
|
|
194
|
+
@click.option(
|
|
195
|
+
"--email",
|
|
196
|
+
help="Email address to authenticate with Standard Ebooks.",
|
|
197
|
+
required=True,
|
|
198
|
+
)
|
|
199
|
+
@click.help_option()
|
|
200
|
+
@click.option(
|
|
201
|
+
"--opds",
|
|
202
|
+
help="URL of Standard Ebooks OPDS catalog.",
|
|
203
|
+
default="https://standardebooks.org/feeds/opds/all",
|
|
204
|
+
)
|
|
205
|
+
@click.option(
|
|
206
|
+
"--quiet",
|
|
207
|
+
help="Suppress non-error messages.",
|
|
208
|
+
is_flag=True,
|
|
209
|
+
)
|
|
210
|
+
@click.option(
|
|
211
|
+
"--type",
|
|
212
|
+
type=click.Choice(list(type_selector.keys())),
|
|
213
|
+
help="EPUB type to download.",
|
|
214
|
+
default="compatible",
|
|
215
|
+
)
|
|
216
|
+
@click.option(
|
|
217
|
+
"--verbose",
|
|
218
|
+
help="Increase verbosity.",
|
|
219
|
+
is_flag=True,
|
|
220
|
+
)
|
|
221
|
+
@click.version_option(package_name="sebsync")
|
|
222
|
+
def sebsync(
|
|
223
|
+
books: Path,
|
|
224
|
+
downloads: Path,
|
|
225
|
+
dry_run: bool,
|
|
226
|
+
email: str,
|
|
227
|
+
force_update: bool,
|
|
228
|
+
opds: str,
|
|
229
|
+
quiet: bool,
|
|
230
|
+
type: str,
|
|
231
|
+
verbose: bool,
|
|
232
|
+
):
|
|
233
|
+
global _dry_run
|
|
234
|
+
global _verbose
|
|
235
|
+
global _quiet
|
|
236
|
+
|
|
237
|
+
_dry_run = dry_run
|
|
238
|
+
_verbose = verbose and not quiet # quiet wins
|
|
239
|
+
_quiet = quiet
|
|
240
|
+
|
|
241
|
+
remote_ebooks = get_remote_ebooks(opds, email, type)
|
|
242
|
+
if not remote_ebooks:
|
|
243
|
+
raise click.ClickException("OPDS download failed. Is email address correct?")
|
|
244
|
+
if _verbose:
|
|
245
|
+
click.echo(f"Found {len(remote_ebooks)} remote ebooks.")
|
|
246
|
+
|
|
247
|
+
local_ebooks = get_local_ebooks(downloads) | get_local_ebooks(books)
|
|
248
|
+
if _verbose:
|
|
249
|
+
click.echo(f"Found {len(local_ebooks)} local ebooks.")
|
|
250
|
+
|
|
251
|
+
for remote_ebook in remote_ebooks.values():
|
|
252
|
+
if local_ebook := local_ebooks.get(remote_ebook.id):
|
|
253
|
+
if remote_ebook.updated != local_ebook.modified or force_update:
|
|
254
|
+
echo_status(local_ebook.path, Status.UPDATE)
|
|
255
|
+
download_ebook(remote_ebook.href, local_ebook.path)
|
|
256
|
+
else:
|
|
257
|
+
path = downloads / ebook_filename(remote_ebook)
|
|
258
|
+
echo_status(path, Status.NEW)
|
|
259
|
+
download_ebook(remote_ebook.href, path)
|
|
260
|
+
|
|
261
|
+
for local_ebook in local_ebooks.values():
|
|
262
|
+
if local_ebook.id not in remote_ebooks:
|
|
263
|
+
echo_status(local_ebook.path, Status.EXTRA)
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def main():
|
|
267
|
+
sebsync(show_default=True)
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
if __name__ == "__main__":
|
|
271
|
+
main()
|