sebsync 1.0.0__tar.gz → 1.0a2__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.
sebsync-1.0a2/PKG-INFO ADDED
@@ -0,0 +1,54 @@
1
+ Metadata-Version: 2.1
2
+ Name: sebsync
3
+ Version: 1.0a2
4
+ Summary: Synchronize Standard Ebooks catalog with local ebooks.
5
+ Home-page: https://github.com/pbryan/sebsync/
6
+ License: MIT
7
+ Keywords: ebook,downloader,standard,book
8
+ Author: sebsync authors
9
+ Requires-Python: >=3.10,<4.0
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Intended Audience :: End Users/Desktop
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Topic :: Other/Nonlisted Topic
18
+ Requires-Dist: click (>=8.1,<9.0)
19
+ Requires-Dist: requests (>=2.32,<3.0)
20
+ Project-URL: Documentation, https://github.com/pbryan/sebsync/tree/main/docs
21
+ Description-Content-Type: text/markdown
22
+
23
+ # sebsync
24
+
25
+ sebsync is a simple script to synchronize all EPUBs from Standard Ebooks to your local
26
+ filesystem. It detects any new and updated titles and downloads them automatically.
27
+
28
+ ## How it works
29
+
30
+ The `sebsync` script reads the Standard Ebooks OPDS catalog and reconciles their titles against
31
+ EPUBs in your local filesystem, downloading new ones or updating existing ones as necessary.
32
+
33
+ If you're member of the Standard Ebooks
34
+ [Patrons Circle](https://standardebooks.org/donate#patrons-circle), or have produced an ebook
35
+ for them in the past, you may access their [OPDS feed](https://standardebooks.org/feeds),
36
+ a catalog of all published titles. This allows new and updated titles to be easily recognized.
37
+
38
+ If a title is detected in the catalog that is not present in your local books or downloads
39
+ directory, then it will be downloaded into your downloads directory. If a new version of a book
40
+ is detected in the catalog that is already in your local books or download directory, then the
41
+ new version will be downloaded and stored in the same place it was found.
42
+
43
+ ## Installation
44
+
45
+ ```
46
+ pipx install sebsync
47
+ ```
48
+
49
+ ### Example usage
50
+
51
+ ```
52
+ sebsync --email address@example.com --books /home/user/MyBooks --downloads /home/user/MyBooks/Downloads
53
+ ```
54
+
@@ -0,0 +1,31 @@
1
+ # sebsync
2
+
3
+ sebsync is a simple script to synchronize all EPUBs from Standard Ebooks to your local
4
+ filesystem. It detects any new and updated titles and downloads them automatically.
5
+
6
+ ## How it works
7
+
8
+ The `sebsync` script reads the Standard Ebooks OPDS catalog and reconciles their titles against
9
+ EPUBs in your local filesystem, downloading new ones or updating existing ones as necessary.
10
+
11
+ If you're member of the Standard Ebooks
12
+ [Patrons Circle](https://standardebooks.org/donate#patrons-circle), or have produced an ebook
13
+ for them in the past, you may access their [OPDS feed](https://standardebooks.org/feeds),
14
+ a catalog of all published titles. This allows new and updated titles to be easily recognized.
15
+
16
+ If a title is detected in the catalog that is not present in your local books or downloads
17
+ directory, then it will be downloaded into your downloads directory. If a new version of a book
18
+ is detected in the catalog that is already in your local books or download directory, then the
19
+ new version will be downloaded and stored in the same place it was found.
20
+
21
+ ## Installation
22
+
23
+ ```
24
+ pipx install sebsync
25
+ ```
26
+
27
+ ### Example usage
28
+
29
+ ```
30
+ sebsync --email address@example.com --books /home/user/MyBooks --downloads /home/user/MyBooks/Downloads
31
+ ```
@@ -8,18 +8,18 @@ target-version = ['py312']
8
8
 
9
9
  [tool.poetry]
10
10
  name = "sebsync"
11
- version = "1.0.0"
12
- description = "Synchronize Standard Ebooks catalog with local EPUB collection."
11
+ version = "1.0alpha2"
12
+ description = "Synchronize Standard Ebooks catalog with local ebooks."
13
13
  readme = "README.md"
14
14
  authors = ["sebsync authors"]
15
15
  homepage = "https://github.com/pbryan/sebsync/"
16
+ documentation = "https://github.com/pbryan/sebsync/tree/main/docs"
16
17
  license = "MIT"
17
- keywords = ["standard", "ebook", "book", "synchronization", "sync", "download"]
18
+ keywords = ["ebook", "downloader", "standard", "book"]
18
19
  classifiers = [
19
- "Development Status :: 5 - Production/Stable",
20
- "Environment :: Console",
20
+ "Development Status :: 3 - Alpha",
21
21
  "Intended Audience :: End Users/Desktop",
22
- "Topic :: System :: Archiving :: Mirroring",
22
+ "Topic :: Other/Nonlisted Topic",
23
23
  ]
24
24
 
25
25
  [tool.poetry.dependencies]
@@ -28,9 +28,9 @@ click = "^8.1"
28
28
  requests = "^2.32"
29
29
 
30
30
  [tool.poetry.dev-dependencies]
31
- black = "^24.8"
31
+ black = "^24.4"
32
32
  isort = "^5.13"
33
- pre-commit = "^3.8"
33
+ pre-commit = "^3.7"
34
34
  pytest = "^8.3"
35
35
  pytest-cov = "^5.0"
36
36
 
@@ -44,4 +44,3 @@ no_sections = true
44
44
 
45
45
  [tool.poetry.scripts]
46
46
  sebsync = "sebsync:main"
47
-
@@ -0,0 +1,203 @@
1
+ """Synchronize Standard Ebooks catalog with local ebooks."""
2
+
3
+ import click
4
+ import requests
5
+ import xml.etree.ElementTree as ElementTree
6
+ import zipfile
7
+
8
+ from dataclasses import dataclass
9
+ from datetime import datetime
10
+ from pathlib import Path
11
+ from requests.auth import HTTPBasicAuth
12
+
13
+
14
+ _dry_run: bool = False
15
+ _quiet: bool = False
16
+ _verbose: bool = False
17
+
18
+
19
+ _epilog = """
20
+ All options can be provided through environment variables in all-caps with SEBSYNC_ prefix.
21
+ """
22
+
23
+
24
+ @dataclass
25
+ class StandardEbook:
26
+ """Metadata for ebook in Standard Ebooks OPDS catalog."""
27
+
28
+ id: str
29
+ title: str
30
+ author: str
31
+ href: str
32
+ updated: datetime
33
+
34
+
35
+ @dataclass
36
+ class LocalEbook:
37
+ """Metadata for ebook in local directory."""
38
+
39
+ id: str
40
+ title: str
41
+ path: Path
42
+ modified: datetime
43
+
44
+
45
+ def _if_exists(path: Path) -> Path | None:
46
+ return path if path.exists() else None
47
+
48
+
49
+ def get_standard_ebooks(opds: str, email: str) -> dict[str, StandardEbook]:
50
+ """Return Standard Ebooks metadata for EPUBs from the OPDS catalog."""
51
+ ns = {"atom": "http://www.w3.org/2005/Atom", "dc": "http://purl.org/dc/terms/"}
52
+ ebooks = {}
53
+ response = requests.get(opds, stream=True, auth=HTTPBasicAuth(email, ""))
54
+ response.raw.decode_content = True
55
+ root = ElementTree.parse(response.raw).getroot()
56
+ for entry in root.iterfind(".//atom:entry", ns):
57
+ ebook = StandardEbook(
58
+ id=entry.find("dc:identifier", ns).text,
59
+ title=entry.find("atom:title", ns).text,
60
+ author=entry.find("atom:author", ns).find("atom:name", ns).text,
61
+ href=entry.find(".//atom:link[@title='Recommended compatible epub']", ns).attrib[
62
+ "href"
63
+ ],
64
+ updated=datetime.fromisoformat(entry.find("atom:updated", ns).text),
65
+ )
66
+ ebooks[ebook.id] = ebook
67
+ return ebooks
68
+
69
+
70
+ def get_local_ebooks(dir: Path) -> dict[str, LocalEbook]:
71
+ """Return metadata of Standard EPUBs in the specified directory and subdirectories."""
72
+ ebooks = {}
73
+ for path in dir.glob("**/*.epub"):
74
+ with zipfile.ZipFile(path) as zip:
75
+ with zip.open("META-INF/container.xml") as file:
76
+ root = ElementTree.parse(file)
77
+ ns = {"container": "urn:oasis:names:tc:opendocument:xmlns:container"}
78
+ rootfile = root.find(".//container:rootfile", ns).attrib["full-path"]
79
+ with zip.open(rootfile) as file:
80
+ root = ElementTree.parse(file)
81
+ ns = {
82
+ "opf": "http://www.idpf.org/2007/opf",
83
+ "dc": "http://purl.org/dc/elements/1.1/",
84
+ }
85
+ metadata = root.find("opf:metadata", ns)
86
+ id = metadata.find("dc:identifier", ns)
87
+ if id is None or "standardebooks.org" not in id.text:
88
+ continue
89
+ modified = metadata.find(".//opf:meta[@property='dcterms:modified']", ns)
90
+ ebook = LocalEbook(
91
+ id=id.text,
92
+ title=metadata.find(".//dc:title", ns).text,
93
+ path=path,
94
+ modified=datetime.fromisoformat(modified.text),
95
+ )
96
+ ebooks[ebook.id] = ebook
97
+ return ebooks
98
+
99
+
100
+ def download_ebook(url: str, path: Path) -> None:
101
+ """Download the ebook at the specified URL into the specified path."""
102
+ if not _quiet:
103
+ click.echo(f"Download: {path}")
104
+ if not _dry_run:
105
+ response = requests.get(url, stream=True)
106
+ with path.open("wb") as file:
107
+ for chunk in response.iter_content(chunk_size=1 * 1024 * 1024):
108
+ file.write(chunk)
109
+
110
+
111
+ def ebook_filename(ebook: StandardEbook) -> str:
112
+ """Return an appropriate EPUB file name for the given ebook author and title."""
113
+ replace = {"/": "-", "‘": "'", "’": "'", '"': "'", "“": "'", "”": "'"}
114
+ author = ebook.author
115
+ title = ebook.title
116
+ names = author.split()
117
+ if len(names) > 1:
118
+ author = f"{names[-1]}, {' '.join(names[:-1])}"
119
+ result = f"{author} - {title}.epub"
120
+ for k, v in replace.items():
121
+ result = result.replace(k, v)
122
+ return result
123
+
124
+
125
+ @click.command(help=__doc__, epilog=_epilog)
126
+ @click.option(
127
+ "--books",
128
+ help="directory where local books are stored",
129
+ type=click.Path(exists=True, file_okay=False, dir_okay=True, path_type=Path),
130
+ default=_if_exists(Path.home() / "Books"),
131
+ )
132
+ @click.option(
133
+ "--downloads",
134
+ help="directory where ebooks are downloaded",
135
+ type=click.Path(exists=True, file_okay=False, dir_okay=True, writable=True, path_type=Path),
136
+ default=_if_exists(Path.home() / "Downloads"),
137
+ )
138
+ @click.option(
139
+ "--dry-run",
140
+ help="perform a trial run with no changes made",
141
+ is_flag=True,
142
+ )
143
+ @click.option(
144
+ "--email",
145
+ help="email to authenticate with Standard Ebooks",
146
+ required=True,
147
+ )
148
+ @click.option(
149
+ "--opds",
150
+ help="URL of Standard Ebooks OPDS catalog",
151
+ default="https://standardebooks.org/feeds/opds/all",
152
+ )
153
+ @click.option(
154
+ "--quiet",
155
+ help="suppress non-error messages",
156
+ is_flag=True,
157
+ )
158
+ @click.option(
159
+ "--verbose",
160
+ help="increase verbosity",
161
+ is_flag=True,
162
+ )
163
+ def sebsync(
164
+ books: str, downloads: str, dry_run: bool, email: str, opds: str, quiet: bool, verbose: bool
165
+ ):
166
+ global _dry_run
167
+ global _verbose
168
+ global _quiet
169
+
170
+ _dry_run = dry_run
171
+ _verbose = verbose
172
+ _quiet = quiet
173
+
174
+ remote_ebooks = get_standard_ebooks(opds, email)
175
+ if not remote_ebooks:
176
+ raise click.ClickException("Email address rejected by Standard Ebooks.")
177
+ if _verbose:
178
+ click.echo(f"Found {len(remote_ebooks)} remote Standard Ebooks titles.")
179
+
180
+ local_ebooks = get_local_ebooks(downloads) | get_local_ebooks(books)
181
+ if _verbose:
182
+ click.echo(f"Found {len(local_ebooks)} local Standard Ebooks titles.")
183
+
184
+ for remote_ebook in remote_ebooks.values():
185
+ if local_ebook := local_ebooks.get(remote_ebook.id):
186
+ if remote_ebook.updated != local_ebook.modified:
187
+ download_ebook(remote_ebook.href, local_ebook.path)
188
+ else:
189
+ path = downloads / ebook_filename(remote_ebook)
190
+ download_ebook(remote_ebook.href, path)
191
+
192
+ if not _quiet:
193
+ for local_ebook in local_ebooks.values():
194
+ if local_ebook.id not in remote_ebooks:
195
+ click.secho(f"Extra: {local_ebook.path}", fg="yellow")
196
+
197
+
198
+ def main():
199
+ sebsync(auto_envvar_prefix="SEBSYNC", show_default=True)
200
+
201
+
202
+ if __name__ == "__main__":
203
+ main()
sebsync-1.0.0/PKG-INFO DELETED
@@ -1,83 +0,0 @@
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
-
sebsync-1.0.0/README.md DELETED
@@ -1,60 +0,0 @@
1
- # sebsync
2
-
3
- sebsync is a simple script to synchronize the [Standard Ebooks](https://standardebooks.org/)
4
- catalog with your local EPUB collection.
5
-
6
- ## How it works
7
-
8
- The script reads the Standard Ebooks [OPDS feed](https://standardebooks.org/feeds) and
9
- reconciles the ebooks in their catalog against the EPUBs in your local filesystem,
10
- downloading new ones or updating existing ones as necessary.
11
-
12
- If a new ebook is detected in the catalog, then it will be downloaded into your downloads
13
- directory. If an updated version of a book is detected in the catalog that is already in your
14
- local filesystem, then the updated version will be downloaded and stored in the existing local
15
- file.
16
-
17
- The script also detects extraneous local ebooks (local ebooks not found in the Standard
18
- Ebooks catalog). This can can occur when Standard Ebooks changes the identifier of a previously
19
- published ebook. It's a rare occurrence, and it's generally safe to delete such files.
20
-
21
- ## Requirements
22
-
23
- This script uses Standard Ebooks' OPDS feed to access metadata on all books their catalog. In
24
- order to do so, you need to be a member of the Standard Ebooks
25
- [Patrons Circle](https://standardebooks.org/donate#patrons-circle) (or have previously produced
26
- an ebook for Standard Ebooks). If you're not already a Standard Ebooks patron, please consider
27
- becoming one. It's a tremendously valuable project, deserving of your support.
28
-
29
- ## Installation
30
-
31
- ```
32
- pipx install sebsync
33
- ```
34
-
35
- ## Example usage
36
-
37
- ```
38
- sebsync --email addr@example.com --books /home/user/MyBooks --downloads /home/user/MyBooks/Downloads
39
- ```
40
-
41
- ## Questions and answers
42
-
43
- Q1. *Why use a separate downloads directory for new ebooks?*
44
-
45
- A1. This is a feature for those who want to easily recognize new ebooks, and to manually
46
- rename and/or categorize them within their library. If this feature is not useful for you,
47
- simply set `--downloads` to be the same directory as `--books`.
48
-
49
- Q2. *What is pipx?*
50
-
51
- A2. [pipx](https://pipx.pypa.io/) is a utility that allows Python packages like sebsync to
52
- be installed in an isolated environment. This makes it really easy to install and run such
53
- scripts without interfering with other Python package installations.
54
-
55
- Q3. *Does this script support Kindle books?*
56
-
57
- A3. Not presently. A cursory analysis of the Standard Ebooks AZW3 structure suggests that there
58
- currently isn't a reliable method to reconcile Kindle ebooks with the OPDS feed catalog. If
59
- this feature is important to you, please let us know by voting for
60
- [this issue](https://github.com/pbryan/sebsync/issues/2) in GitHub.
sebsync-1.0.0/sebsync.py DELETED
@@ -1,271 +0,0 @@
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()
File without changes
File without changes