papir-kindle 1.0__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.
@@ -0,0 +1,27 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Benjamin M. Duivesteyn
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
22
+
23
+ ---
24
+
25
+ Protocol lineage: this project re-implements the private Send to Kindle
26
+ protocol following maxdjohnson/stkclient (MIT, (c) 2022 Max Johnson) and
27
+ cyrgim/stk. No Amazon code is included. Unofficial, unaffiliated with Amazon.
@@ -0,0 +1,136 @@
1
+ Metadata-Version: 2.4
2
+ Name: papir-kindle
3
+ Version: 1.0
4
+ Summary: Beam reading material to your e-reader via API, with correct Author + Title.
5
+ Author-email: "Benjamin M. Duivesteyn" <duivesteyn@gmail.com>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/duivesteyn/Papir
8
+ Keywords: kindle,e-reader,send-to-kindle,epub,pdf
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Environment :: Console
12
+ Classifier: Topic :: Utilities
13
+ Requires-Python: >=3.9
14
+ Description-Content-Type: text/markdown
15
+ License-File: LICENSE
16
+ Requires-Dist: requests
17
+ Dynamic: license-file
18
+
19
+ <img src="_screenshots/Papir-256.png" width="128" alt="Papir icon — folded ivory paper P on forest green">
20
+
21
+ # Papir
22
+
23
+ A lightweight utility to beam reading material directly to your e-reader via API.
24
+
25
+ > **Python CLI + native Mac companion.** `papir` remains the Python CLI.
26
+ > A self-contained, on-demand SwiftUI `Papir.app` lives in `macos/`, with bundled
27
+ > Python and browser sign-in; see [Mac setup](macos/README.md).
28
+
29
+ <img src="_screenshots/Papir-app-window.png" width="480" alt="Papir Mac app: forest-green window with serif papir header inline with the traffic lights, destination pill and document drop zone">
30
+
31
+ `papir` is Norwegian for paper. p-[api]-r hides API in the middle.
32
+
33
+ Unofficial. Not affiliated with Amazon / Kindle. Speaks the private protocol used by
34
+ Amazon's Send to Kindle desktop app (following `stkclient` by Max Johnson, MIT, and
35
+ `cyrgim/stk`). Amazon can break it without notice — use on an account you own.
36
+
37
+ # Downloads and project layout
38
+
39
+ [Mac releases](https://github.com/duivesteyn/Papir/releases) ·
40
+ [Mac installation](macos/INSTALL.md) · [Local builds](macos/README.md)
41
+
42
+ The Mac download includes Python and browser sign-in. Releases are unnotarized;
43
+ see the install instructions for macOS approval. Early releases are marked as
44
+ pre-releases while fresh-account/device-delivery and older-OS testing continues.
45
+
46
+ Everything for this mini project lives in this repository:
47
+
48
+ - `papir/`: Python CLI, protocol and shared sending engine.
49
+ - `macos/`: native Mac UI, bundling, verification and packaging.
50
+ - `planning/`: current plan and retained protocol/package research.
51
+ - `art/`: original icon master, fixed exports, asset catalog and style spec.
52
+ - `.github/workflows/`: offline CI and tagged Mac release builds.
53
+
54
+ Build environments and generated releases stay local and are ignored by Git.
55
+ Downloadable binaries belong in GitHub Releases, not in source commits.
56
+
57
+ # Example Usage
58
+
59
+ An example usage file is included called main.py. It contains the basics. In essence:
60
+
61
+ ```python
62
+ from papir import papirSend
63
+
64
+ sku = papirSend("report.pdf", author="deCapital", title="Imperial Investment Case", target="all")
65
+ print(sku)
66
+
67
+ # author/title optional — author falls back to your stored default, title to the filename:
68
+ sku = papirSend("report.pdf")
69
+ ```
70
+
71
+ CLI:
72
+
73
+ ```
74
+ pip install .
75
+ papir login # sign in, set default author + Kindle
76
+ papir login --url URL --verifier VERIFIER # non-interactive (or --code CODE for a bare pasted code)
77
+ papir devices # SERIAL: Name (* marks the default)
78
+ papir devices --json # machine-readable devices + default
79
+ papir set-default # arrow-key/number picker (or `papir --set-default`)
80
+ papir set-default SERIAL # non-interactive (or `all` to clear)
81
+ papir set-default --author "deCapital"
82
+ papir send report.pdf
83
+ papir send report.pdf --author "deCapital" --title "Imperial Investment Case" --to DEVICE_SERIAL
84
+ papir send report.pdf --convert # convert PDF to Kindle format (default keeps native PDF)
85
+ papir document.pdf --title "Design Systems" # shorthand, uses defaults
86
+ papir doctor [--live]
87
+ papir logout
88
+ ```
89
+
90
+ `papir send` without `--to` uses your default Kindle (else all devices).
91
+ `--to` accepts a serial, `all`, or `library` (cloud-library-only, no device needed).
92
+ `--format` overrides the detected file type (default `auto`); `--no-archive` skips saving to your Kindle library.
93
+ `--client PATH` overrides the credential file (default `~/.config/papir/client.json`).
94
+
95
+ # Example Output Data
96
+
97
+ `papirSend` returns the sku Amazon assigns:
98
+
99
+ ```
100
+ 8A47909664E246F0BAAB1738D7E9A154
101
+ ```
102
+
103
+ # Brand
104
+
105
+ Folded ivory paper P on forest green (`#123325` / ivory `#F3EADB`).
106
+ Full spec: [art/Papir-art-style-spec.md](art/Papir-art-style-spec.md). The Mac app uses an on-demand forest-green window and the retained app icon.
107
+ The CLI's only artwork is 📤/✅ in terminal output.
108
+
109
+ # Changelog
110
+
111
+ - v1.0 2026-10-17 Stable 1.0. Python package `papir-kindle` (`pip install papir-kindle`, CLI stays `papir`), Mac app + CLI.
112
+ - v0.2.2 2026-10-06 Forest-green native title bars across app windows.
113
+
114
+ - v0.2.0 2026-10-06 Self-contained native Mac app, browser sign-in/preferences, JSON desktop bridge, shared library-only default fix, consolidated planning/art and release builds.
115
+
116
+ - v0.1.1 2026-10-06 Native-PDF fix: dropped stkclient's invented `outputFormat=MOBI`/`deliveryMechanism` (backend converted PDFs), send `forceConvert:false` like the official app. Added `--convert` opt-in.
117
+ - v0.1.0 2026-10-06 Initial revision. login/devices/send/logout/doctor, correct Author+Title via DocumentMetadata.
118
+
119
+ # Python package
120
+
121
+ ```bash
122
+ pip install papir-kindle
123
+ papir --help
124
+ ```
125
+
126
+ Releases: pushing tag `v*` runs `.github/workflows/pypi.yml` — offline tests, `python -m build`, Trusted Publishing to PyPI as `papir-kindle`, then attaches `dist/*` to the GitHub Release. First publish needs a PyPI Trusted Publisher for this repo + workflow.
127
+
128
+ # Inspirations
129
+
130
+ - https://github.com/maxdjohnson/stkclient
131
+ - https://github.com/cyrgim/stk
132
+
133
+ # Credits
134
+
135
+ designed in 2026 by bmd. Protocol work: `maxdjohnson/stkclient` (MIT) + `cyrgim/stk`
136
+ (bare-digest signing quirk, per-install serials).
@@ -0,0 +1,118 @@
1
+ <img src="_screenshots/Papir-256.png" width="128" alt="Papir icon — folded ivory paper P on forest green">
2
+
3
+ # Papir
4
+
5
+ A lightweight utility to beam reading material directly to your e-reader via API.
6
+
7
+ > **Python CLI + native Mac companion.** `papir` remains the Python CLI.
8
+ > A self-contained, on-demand SwiftUI `Papir.app` lives in `macos/`, with bundled
9
+ > Python and browser sign-in; see [Mac setup](macos/README.md).
10
+
11
+ <img src="_screenshots/Papir-app-window.png" width="480" alt="Papir Mac app: forest-green window with serif papir header inline with the traffic lights, destination pill and document drop zone">
12
+
13
+ `papir` is Norwegian for paper. p-[api]-r hides API in the middle.
14
+
15
+ Unofficial. Not affiliated with Amazon / Kindle. Speaks the private protocol used by
16
+ Amazon's Send to Kindle desktop app (following `stkclient` by Max Johnson, MIT, and
17
+ `cyrgim/stk`). Amazon can break it without notice — use on an account you own.
18
+
19
+ # Downloads and project layout
20
+
21
+ [Mac releases](https://github.com/duivesteyn/Papir/releases) ·
22
+ [Mac installation](macos/INSTALL.md) · [Local builds](macos/README.md)
23
+
24
+ The Mac download includes Python and browser sign-in. Releases are unnotarized;
25
+ see the install instructions for macOS approval. Early releases are marked as
26
+ pre-releases while fresh-account/device-delivery and older-OS testing continues.
27
+
28
+ Everything for this mini project lives in this repository:
29
+
30
+ - `papir/`: Python CLI, protocol and shared sending engine.
31
+ - `macos/`: native Mac UI, bundling, verification and packaging.
32
+ - `planning/`: current plan and retained protocol/package research.
33
+ - `art/`: original icon master, fixed exports, asset catalog and style spec.
34
+ - `.github/workflows/`: offline CI and tagged Mac release builds.
35
+
36
+ Build environments and generated releases stay local and are ignored by Git.
37
+ Downloadable binaries belong in GitHub Releases, not in source commits.
38
+
39
+ # Example Usage
40
+
41
+ An example usage file is included called main.py. It contains the basics. In essence:
42
+
43
+ ```python
44
+ from papir import papirSend
45
+
46
+ sku = papirSend("report.pdf", author="deCapital", title="Imperial Investment Case", target="all")
47
+ print(sku)
48
+
49
+ # author/title optional — author falls back to your stored default, title to the filename:
50
+ sku = papirSend("report.pdf")
51
+ ```
52
+
53
+ CLI:
54
+
55
+ ```
56
+ pip install .
57
+ papir login # sign in, set default author + Kindle
58
+ papir login --url URL --verifier VERIFIER # non-interactive (or --code CODE for a bare pasted code)
59
+ papir devices # SERIAL: Name (* marks the default)
60
+ papir devices --json # machine-readable devices + default
61
+ papir set-default # arrow-key/number picker (or `papir --set-default`)
62
+ papir set-default SERIAL # non-interactive (or `all` to clear)
63
+ papir set-default --author "deCapital"
64
+ papir send report.pdf
65
+ papir send report.pdf --author "deCapital" --title "Imperial Investment Case" --to DEVICE_SERIAL
66
+ papir send report.pdf --convert # convert PDF to Kindle format (default keeps native PDF)
67
+ papir document.pdf --title "Design Systems" # shorthand, uses defaults
68
+ papir doctor [--live]
69
+ papir logout
70
+ ```
71
+
72
+ `papir send` without `--to` uses your default Kindle (else all devices).
73
+ `--to` accepts a serial, `all`, or `library` (cloud-library-only, no device needed).
74
+ `--format` overrides the detected file type (default `auto`); `--no-archive` skips saving to your Kindle library.
75
+ `--client PATH` overrides the credential file (default `~/.config/papir/client.json`).
76
+
77
+ # Example Output Data
78
+
79
+ `papirSend` returns the sku Amazon assigns:
80
+
81
+ ```
82
+ 8A47909664E246F0BAAB1738D7E9A154
83
+ ```
84
+
85
+ # Brand
86
+
87
+ Folded ivory paper P on forest green (`#123325` / ivory `#F3EADB`).
88
+ Full spec: [art/Papir-art-style-spec.md](art/Papir-art-style-spec.md). The Mac app uses an on-demand forest-green window and the retained app icon.
89
+ The CLI's only artwork is 📤/✅ in terminal output.
90
+
91
+ # Changelog
92
+
93
+ - v1.0 2026-10-17 Stable 1.0. Python package `papir-kindle` (`pip install papir-kindle`, CLI stays `papir`), Mac app + CLI.
94
+ - v0.2.2 2026-10-06 Forest-green native title bars across app windows.
95
+
96
+ - v0.2.0 2026-10-06 Self-contained native Mac app, browser sign-in/preferences, JSON desktop bridge, shared library-only default fix, consolidated planning/art and release builds.
97
+
98
+ - v0.1.1 2026-10-06 Native-PDF fix: dropped stkclient's invented `outputFormat=MOBI`/`deliveryMechanism` (backend converted PDFs), send `forceConvert:false` like the official app. Added `--convert` opt-in.
99
+ - v0.1.0 2026-10-06 Initial revision. login/devices/send/logout/doctor, correct Author+Title via DocumentMetadata.
100
+
101
+ # Python package
102
+
103
+ ```bash
104
+ pip install papir-kindle
105
+ papir --help
106
+ ```
107
+
108
+ Releases: pushing tag `v*` runs `.github/workflows/pypi.yml` — offline tests, `python -m build`, Trusted Publishing to PyPI as `papir-kindle`, then attaches `dist/*` to the GitHub Release. First publish needs a PyPI Trusted Publisher for this repo + workflow.
109
+
110
+ # Inspirations
111
+
112
+ - https://github.com/maxdjohnson/stkclient
113
+ - https://github.com/cyrgim/stk
114
+
115
+ # Credits
116
+
117
+ designed in 2026 by bmd. Protocol work: `maxdjohnson/stkclient` (MIT) + `cyrgim/stk`
118
+ (bare-digest signing quirk, per-install serials).
@@ -0,0 +1,29 @@
1
+ #!/usr/bin/env python3
2
+ # coding: utf-8
3
+ #
4
+ # duivesteyn // Python // papir
5
+ # https://github.com/duivesteyn/papir
6
+ #
7
+ # # bmd 2026
8
+
9
+ """Papir — beam reading material to your e-reader via API."""
10
+
11
+ from papir.papir import (
12
+ clean_title,
13
+ get_default_author,
14
+ get_default_target,
15
+ papirDevices,
16
+ papirLogin,
17
+ papirLogout,
18
+ papirSend,
19
+ resolve_author,
20
+ resolve_title,
21
+ set_default_author,
22
+ set_default_target,
23
+ )
24
+
25
+ __all__ = ["papirSend", "papirLogin", "papirDevices", "papirLogout",
26
+ "clean_title", "resolve_author", "resolve_title",
27
+ "get_default_target", "set_default_target",
28
+ "get_default_author", "set_default_author"]
29
+ __version__ = "1.0"
@@ -0,0 +1,300 @@
1
+ #!/usr/bin/env python3
2
+ # coding: utf-8
3
+ #
4
+ # duivesteyn // Python // papir
5
+ # https://github.com/duivesteyn/papir
6
+ #
7
+ # Typed wrappers for the Amazon auth + STK APIs. Uses requests (duivesteyn style).
8
+ # Protocol is reverse-engineered from Amazon's Send to Kindle desktop app,
9
+ # following stkclient (Max Johnson, MIT) + cyrgim/stk. Unofficial.
10
+ #
11
+ # # bmd 2026
12
+
13
+ """Amazon auth + Send-to-Kindle HTTP calls."""
14
+
15
+ import json
16
+ import urllib.parse
17
+
18
+ import requests
19
+
20
+ from papir import signer
21
+
22
+ CLIENT_ID = "658490dfb190e494030082836775981fa23be0c2425441860352ba0f55915b43002d"
23
+ DEVICE_TYPE = "A1K6D1WRW0MALS"
24
+ PID = "D21NN3GG"
25
+ SOFTWARE_VER = "253"
26
+ OS_VERSION = "MacOSX_10.14.6_x64"
27
+
28
+ TOKEN_HOST = "api.amazon.com"
29
+ FIRS_HOST = "firs-ta-g7g.amazon.com"
30
+ STK_HOST = "stkservice.amazon.com"
31
+
32
+ CLIENT_INFO = {
33
+ "appName": "ShellExtension",
34
+ "appVersion": "1.1.1.253",
35
+ "os": OS_VERSION,
36
+ "osArchitecture": "x64",
37
+ }
38
+
39
+ TIMEOUT = 30
40
+
41
+
42
+ class APIError(ValueError):
43
+ """Amazon returned an HTTP error. .body holds the decoded server message."""
44
+
45
+ def __init__(self, msg, body=None):
46
+ self.body = body
47
+ if body is not None:
48
+ try:
49
+ body = json.loads(body) if isinstance(body, (bytes, str)) else body
50
+ except (json.JSONDecodeError, UnicodeDecodeError, ValueError):
51
+ pass
52
+ try:
53
+ msg += f" {json.dumps(body)[:2000]}"
54
+ except (TypeError, ValueError):
55
+ msg += f" {str(body)[:2000]}"
56
+ super().__init__(msg)
57
+
58
+
59
+ def get_signin_url(verifier):
60
+ """Build the Amazon sign-in URL for a PKCE verifier. Open in a browser."""
61
+ import base64
62
+ import hashlib
63
+ challenge = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).rstrip(b"=").decode()
64
+ q = {
65
+ "openid.claimed_id": "http://specs.openid.net/auth/2.0/identifier_select",
66
+ "openid.ns.oa2": "http://www.amazon.com/ap/ext/oauth/2",
67
+ "openid.ns": "http://specs.openid.net/auth/2.0",
68
+ "openid.identity": "http://specs.openid.net/auth/2.0/identifier_select",
69
+ "openid.oa2.client_id": "device:" + CLIENT_ID,
70
+ "openid.mode": "checkid_setup",
71
+ "openid.oa2.scope": "device_auth_access",
72
+ "openid.oa2.response_type": "code",
73
+ "openid.oa2.code_challenge": challenge,
74
+ "openid.oa2.code_challenge_method": "S256",
75
+ "openid.return_to": "https://www.amazon.com/gp/sendtokindle",
76
+ "openid.ns.pape": "http://specs.openid.net/extensions/pape/1.0",
77
+ "openid.pape.max_auth_age": "0",
78
+ "accountStatusPolicy": "P1",
79
+ "openid.assoc_handle": "amzn_device_na",
80
+ "pageId": "amzn_device_common_dark",
81
+ "disableLoginPrepopulate": "1",
82
+ }
83
+ return "https://www.amazon.com/ap/signin?" + urllib.parse.urlencode(q)
84
+
85
+
86
+ def parse_authorization_code(redirect_url):
87
+ """Pull openid.oa2.authorization_code out of the final redirect URL.
88
+
89
+ Also accepts a bare code (e.g. ANprBJrehznaOHilnEIHemzX) for when the
90
+ terminal truncates the paste — the code is the only part we need.
91
+ """
92
+ s = redirect_url.strip().strip("'\"")
93
+ if s and "://" not in s and "?" not in s and "=" not in s and " " not in s and len(s) < 200:
94
+ return s # bare authorization code, no URL parsing needed
95
+ u = urllib.parse.urlparse(redirect_url.strip())
96
+ q = urllib.parse.parse_qs(u.query)
97
+ try:
98
+ return q["openid.oa2.authorization_code"][0]
99
+ except (KeyError, IndexError):
100
+ raise ValueError("no openid.oa2.authorization_code in redirect URL — sign-in did not complete")
101
+
102
+
103
+ def token_exchange(authorization_code, code_verifier):
104
+ """Exchange authorization_code for an access_token. Returns the token string."""
105
+ body = {
106
+ "app_name": "Unknown",
107
+ "client_domain": "DeviceLegacy",
108
+ "client_id": CLIENT_ID,
109
+ "code_algorithm": "SHA-256",
110
+ "code_verifier": code_verifier,
111
+ "requested_token_type": "access_token",
112
+ "source_token": authorization_code,
113
+ "source_token_type": "authorization_code",
114
+ }
115
+ try:
116
+ r = requests.post(
117
+ f"https://{TOKEN_HOST}/auth/token",
118
+ json=body,
119
+ headers={"Accept-Language": "en-US", "x-amzn-identity-auth-domain": TOKEN_HOST,
120
+ "Content-Type": "application/json", "User-Agent": "Mozilla/5.0"},
121
+ timeout=TIMEOUT,
122
+ )
123
+ r.raise_for_status()
124
+ return r.json()["access_token"]
125
+ except requests.HTTPError as e:
126
+ raise APIError(str(e), e.response.content if e.response is not None else None) from e
127
+
128
+
129
+ def register_device_with_token(access_token, serial, device_name):
130
+ """Register a new device. Returns dict device_info (persist as client.json)."""
131
+ import xml.etree.ElementTree as ET
132
+
133
+ def esc(s):
134
+ return s.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
135
+
136
+ xml_body = (
137
+ "<?xml version='1.0' encoding='UTF-8'?><request><parameters>"
138
+ + "".join(f"<{t}>{esc(v)}</{t}>" for t, v in [
139
+ ("deviceType", DEVICE_TYPE),
140
+ ("deviceSerialNumber", serial),
141
+ ("pid", PID),
142
+ ("authToken", access_token),
143
+ ("authTokenType", "AccessToken"),
144
+ ("softwareVersion", SOFTWARE_VER),
145
+ ("os_version", OS_VERSION),
146
+ ("device_model", device_name),
147
+ ])
148
+ + "</parameters></request>"
149
+ )
150
+ try:
151
+ r = requests.post(
152
+ f"https://{FIRS_HOST}/FirsProxy/registerDeviceWithToken",
153
+ data=xml_body.encode(),
154
+ headers={"Content-Type": "text/xml", "Accept-Language": "en-US,*", "User-Agent": "Mozilla/5.0"},
155
+ timeout=TIMEOUT,
156
+ )
157
+ r.raise_for_status()
158
+ root = ET.fromstring(r.content)
159
+ info = {el.tag: el.text for el in root}
160
+ # keep only known fields, require the two secrets
161
+ out = {
162
+ "device_private_key": info.get("device_private_key"),
163
+ "adp_token": info.get("adp_token"),
164
+ "device_type": info.get("device_type", DEVICE_TYPE),
165
+ "device_serial_number": serial,
166
+ "device_name": device_name,
167
+ "account_name": info.get("name", ""),
168
+ "home_region": info.get("home_region", ""),
169
+ }
170
+ if not out["device_private_key"] or not out["adp_token"]:
171
+ raise APIError("incomplete registration response", r.content[:2000])
172
+ return out
173
+ except requests.HTTPError as e:
174
+ raise APIError(str(e), e.response.content if e.response is not None else None) from e
175
+
176
+
177
+ def _signed_post(path, device_info, body):
178
+ """POST JSON to stkservice with ADP headers. Returns decoded dict."""
179
+ data = json.dumps({"ClientInfo": CLIENT_INFO, **body}, indent=4)
180
+ digest = signer.digest_header_for_request(
181
+ device_info["device_private_key"], device_info["adp_token"], "POST", path, data)
182
+ try:
183
+ r = requests.post(
184
+ f"https://{STK_HOST}{path}",
185
+ data=data.encode(),
186
+ headers={
187
+ "Accept": "application/json",
188
+ "Accept-Encoding": "gzip, deflate",
189
+ "Content-Type": "application/json",
190
+ "X-ADP-Request-Digest": digest,
191
+ "X-ADP-Authentication-Token": device_info["adp_token"],
192
+ "Accept-Language": "en-US,*",
193
+ "User-Agent": "Mozilla/5.0",
194
+ },
195
+ timeout=TIMEOUT,
196
+ )
197
+ r.raise_for_status()
198
+ return r.json()
199
+ except requests.HTTPError as e:
200
+ raise APIError(str(e), e.response.content if e.response is not None else None) from e
201
+
202
+
203
+ def get_owned_devices(device_info):
204
+ """Return list of {deviceName, deviceSerialNumber, deviceCapabilities}."""
205
+ res = _signed_post("/GetListOfOwnedDevices", device_info, {})
206
+ return res.get("ownedDevices", [])
207
+
208
+
209
+ def get_upload_url(device_info, file_size):
210
+ """Return (upload_url, stk_token)."""
211
+ res = _signed_post("/GetUploadUrl", device_info, {"fileSize": file_size})
212
+ return res["uploadUrl"], res["stkToken"]
213
+
214
+
215
+ class _ProgressReader:
216
+ """File-like wrapper reporting bytes read. Lets requests stream with Content-Length."""
217
+
218
+ def __init__(self, fp, total, on_progress):
219
+ self._fp = fp
220
+ self._total = total
221
+ self._sent = 0
222
+ self._cb = on_progress
223
+
224
+ def __len__(self):
225
+ return self._total
226
+
227
+ def read(self, n=-1):
228
+ chunk = self._fp.read(n)
229
+ if chunk:
230
+ self._sent += len(chunk)
231
+ try:
232
+ self._cb(self._sent, self._total)
233
+ except Exception: # noqa: BLE001 — progress must never break the upload
234
+ pass
235
+ return chunk
236
+
237
+
238
+ def upload_file(url, file_size, fp, on_progress=None):
239
+ """Stream fp to the presigned S3 URL via PUT. Raises APIError on non-200."""
240
+ body = _ProgressReader(fp, file_size, on_progress) if on_progress else fp
241
+ try:
242
+ r = requests.put(
243
+ url,
244
+ data=body,
245
+ headers={
246
+ "Content-Length": str(file_size),
247
+ "Accept-Language": "en-US,*",
248
+ "User-Agent": "Mozilla/5.0",
249
+ },
250
+ timeout=120,
251
+ )
252
+ if r.status_code != 200:
253
+ raise APIError(f"upload HTTP {r.status_code} {r.reason}", r.content[:2000])
254
+ except requests.RequestException as e:
255
+ raise APIError(str(e)) from e
256
+
257
+
258
+ def build_send_body(stk_token, targets, author, title, fmt, archive=True, force_convert=False):
259
+ """Build the /SendToKindle JSON body mirroring the official desktop app.
260
+
261
+ Keys observed in the app binary: archive, targetDevices, title/author/
262
+ inputFormat/crc32 under DocumentMetadata, forceConvert, stkToken.
263
+ Deliberately NOT sent: outputFormat/deliveryMechanism — the app never
264
+ sends them, and outputFormat=MOBI makes the backend convert PDFs.
265
+ forceConvert=False keeps the native PDF (the app's unchecked checkbox).
266
+ """
267
+ return {
268
+ "DocumentMetadata": {"author": author, "crc32": 0, "inputFormat": fmt, "title": title},
269
+ "archive": archive,
270
+ "forceConvert": force_convert,
271
+ "stkToken": stk_token,
272
+ "targetDevices": targets,
273
+ }
274
+
275
+
276
+ def send_to_kindle(device_info, stk_token, targets, author, title, fmt, archive=True, force_convert=False):
277
+ """Final delivery call. Returns sku string."""
278
+ body = build_send_body(stk_token, targets, author, title, fmt, archive, force_convert)
279
+ res = _signed_post("/SendToKindle", device_info, body)
280
+ return res.get("sku", "")
281
+
282
+
283
+ def logout(device_info):
284
+ """Disown the device server-side. Best-effort; always delete local creds after."""
285
+ path = "/FirsProxy/disownFiona?contentDeleted=false"
286
+ digest = signer.digest_header_for_request(
287
+ device_info["device_private_key"], device_info["adp_token"], "GET", path, "")
288
+ try:
289
+ requests.get(
290
+ f"https://{FIRS_HOST}{path}",
291
+ headers={
292
+ "X-ADP-Request-Digest": digest,
293
+ "X-ADP-Authentication-Token": device_info["adp_token"],
294
+ "Accept-Language": "en-US,*",
295
+ "User-Agent": "Mozilla/5.0",
296
+ },
297
+ timeout=TIMEOUT,
298
+ )
299
+ except requests.RequestException:
300
+ pass